綴Photo で「クラウドに何が出るか」を本人に見せる機能を足したときの、実装をどこに置くか決めた判断メモです。
その前に、前提を1つ。ここで言う「web」は、いま作っている Web アプリ(紡Book)のこと。端末で綴ったものを、選んだぶんだけそちらへ上げられるようにしている。この記事は、その上げる手前に挟む確認画面をどこに置いたかの話になる。
「隠す」でなく「明示する」と決めた
綴Photo では、選んだ写真だけを web に上げられる。上げる中身はすでに絞ってある(位置情報やカメラの機材情報は EXIF ごと剥がしてから送る)。足りていなかったのは、それをユーザー本人が上げる前に確認できる画面だけだった。漏れないように作ってあることと、本人がそれを知っていることは別で、後者は実装ではなく画面でしか渡せない。
ここで方向を二つに分けて考えた。撮影日時みたいなメタを「隠す(送らない)」方向か、送るものはそのままに「何が出るかを明示する」方向か。綴った気持ちを後で web 側で時系列に並べるには撮影日時が要る。だから隠す方には倒さず、明示する側で担保すると決めた。同期の挙動そのものは一切変えない、が前提になった。
やることは、写真を web に出す「選ぶ」ボタン(この記事では公開ボタン / publish とも呼ぶ、同じ操作)を押した初回に、出るもの・出ないものを並べた確認シートを一度だけ挟む。「続ける」で従来通り公開、「やめておく」で何もしない。それだけ。なお公開まわりの導線はまだ開発ビルド限定で、本番のログイン導線が通ったところで一緒に開く(この記事は実装の置き場所の話)。
見せる場所は初回の確認シートと、設定の常設ページの2つにした。オンボーディングに足すのは外した。同期を使わない人にとっては、最初に読まされる意味のない画面が1枚増えるだけだから。かわりに設定へ同じ内容をいつでも開ける行として置いた。あとから見返す口が常にあるぶん、確認の場は初回の一度に絞れる。
そのうえで、設定から見ただけでは「確認済み」にしない。「読んだ」と「これを公開すると決めた」は別のことで、後者は公開しようとしているその瞬間にしか成立しないと考えた。だから設定側のページは閉じてもフラグに触らず、初回の確認は publish の文脈で必ず一度踏ませる。設定を開いた後に公開してシートが出るのは、記録し忘れているのではなく仕様。
どこに挟むか — コアは触りたくなかった
問題は置き場所だった。公開処理は SyncCoordinator という調整役に集約してある。
つまり SyncCoordinator は「順序と鮮度を絶対に間違えない」ことに責任を持つ場所だ。ここに「初回だけシートを出す」みたいな UI の都合を混ぜたくなかった。確認シートは画面の話であって、送信手順の正しさとは無関係だから。
なので coordinator には指一本触れず、UI 層(詳細画面で公開ボタンを押したときに走る処理)の入り口にゲートを1個置く方針にした。
戻り値でゲートする関数
「確認済み」の記録先を DB でなく SharedPreferences にしたのは、この分け方に従っただけだ。同期されるものは DB、その端末にだけ閉じるものは SharedPreferences。ローカルの記録はローカルに置く、というだけの話で、機種を変えたらシートがもう一度出るのは、そう決めたというよりそこから出てくる帰結になる。
置いたのはこれだけの関数。コードは AI に書いてもらっている。自分が決めたのは、置き場所をコアでなく UI 層にすることと、返り値を bool 1個に絞ることの2点だ。
/// publish の初回だけ確認シートを挟み、続行可否を返す。
/// true=進んでよい / false=やめておく(呼び出し側は何もしない)。
Future<bool> ensureSyncContentNoticeAccepted({ // 返すのは true / false の2択だけ
required BuildContext context,
required SyncContentNoticeFlag flag, // 「一度 OK したか」の記録係
}) async {
if (await flag.isAccepted()) return true; // 2回目以降は素通り
if (!context.mounted) return false; // 画面がもう閉じていたら何もしない
final proceed = await showSyncContentNoticeConfirm(context); // ここで確認シートを出して待つ
if (proceed != true) return false; // やめておく / 外タップ
await flag.markAccepted(); // 続けるを踏んだら記録
return true;
}呼ぶ側はこう。既存の公開処理の一番上に呼び出し1つとガード1行を足しただけで、その下(publishOne を呼ぶ本体)は元のまま。
Future<void> publishMemory(MemoryItem item) async {
final accepted = await ensureSyncContentNoticeAccepted( // ← ゲートを通す
context: context,
flag: ref.read(syncContentNoticeFlagProvider), // 記録係を取り出して渡す
);
if (!accepted || !mounted) return; // やめておく=ここで打ち切り
// ...以下、従来どおり publishOne を呼ぶ(=ここから先は一切変えていない)
}書いてから気付いたのは、「やめておく」で何も起きないことが構造的に保証されている点だった。ユーザーが「選ぶ」を押して公開フラグを立てる経路は SyncCoordinator.publishOne の中の一行しかない(この記事のあと、公開の取り消しに失敗したときの巻き戻しと、サーバの公開状態との突き合わせが別経路として増えた。どちらも「押して選ぶ」操作ではないので、以下の話は変わらない)。コードの中で1か所にしか書かれていない処理は、その外側の入れ物を呼ばない限り絶対に動かない。
Future<SyncResultSummary> publishOne(int photoId) async {
await _ensureAuthReady();
await _db.photoDao.setWebSelected(photoId, true); // ← 選択を確定するのはここだけ
// ...取り直して送る
}ゲートが false を返せば publishOne に入らない。入らなければ公開フラグを立てる一行に到達しない。だから「やめておく」を押したときに公開フラグがうっかり立つ、という経路がそもそも存在しない。フラグ管理を別で書いて「やめておくなら立てないように気をつける」んじゃなく、呼ばなければ何もしないという置き方に寄せたぶん、気をつける対象が消えた。
もっとも、これは交換をこちら側に倒したというだけの話でもある。コアにゲートを置けば全経路で漏れないかわりに、UI の都合がコアに入る。UI 側に置けばコアは綺麗なまま保てるが、公開の入口が増えたときゲートは自動では付いてこない。今は「選ぶ」の入口が詳細画面の1本しかないので、そこに数行足せば全部通したことになる。入口を増やすときは増やす側がゲートも通す、が引き受けた条件になる。
副産物 — 画面をまるごと立ち上げずにテストできた
このゲート関数、テストのときに効いた。
「やめておくで公開されないこと」を確かめたい。素直にやるなら詳細画面をまるごと立ち上げて、シートを出して、やめておくを押して、DB の webSelected が変わっていないことを見る。でも詳細画面のフル pump は重い ── 画像デコード、複数の Provider、DB のセットアップが芋づるで付いてくる。確認したいのはシートの分岐1個なのに。
そこでゲート関数の戻り値だけを固定することにした。関数を小さな Builder に差して押すだけ。
testWidgets('「やめておく」: false が返りフラグは立たない(publish 不発=webSelected 不変)', (tester) async {
// ...ゲートを1個のボタンに差して開く
await tester.tap(find.text('やめておく')); // 「やめておく」と書かれたボタンを押す
await tester.pumpAndSettle(); // 画面の動きが止まるまで進める
expect(result, isFalse); // expect(調べる値,期待する値)。false で十分
final prefs = await SharedPreferences.getInstance(); // 端末に残る小さな設定の保管場所
expect(prefs.getBool(syncContentNoticeAcceptedKey), isNull); // isNull=一度も記録されていない
});setWebSelected が呼ばれないことをわざわざ検証しなくていい。さっきの構造 ── false が返る=publishOne に入らない=webSelected は動かない ── が成り立っているので、戻り値の false がそのまま「公開されない」の証明になる。実際の配線(ハンドラの数行)は実機で一周見て確かめる側に回した。分岐の網羅は軽いユニットで、繋がりは実機で、と切り分けたぶん、テストが軽くなった。
コアを汚さないために UI 層へ関数を切り出したら、その関数がそのままテストの単位になった。これは狙って揃えたわけではなく、テストを書く段になって気付いた。次に「コアに触らず入り口で分岐させたい」が来たら、最初からこの単位で切ると思う。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。