綴Photo の勉強ノートに戻る
プロダクト設計2026.07.10

同期のコアを触らずに『何が公開されるか』を見せる — ゲート関数という置き場所

DartFlutter設計綴Photo

綴Photo で「クラウドに何が出るか」を本人に見せる機能を足したときの、実装をどこに置くか決めた判断メモです。

その前に、前提を1つ。ここで言う「web」は、いま作っている Web アプリ(紡Book)のこと。端末で綴ったものを、選んだぶんだけそちらへ上げられるようにしている。この記事は、その上げる手前に挟む確認画面をどこに置いたかの話になる。

「隠す」でなく「明示する」と決めた

綴Photo では、選んだ写真だけを web に上げられる。上げる中身はすでに絞ってある(位置情報やカメラの機材情報は EXIF ごと剥がしてから送る)。足りていなかったのは、それをユーザー本人が上げる前に確認できる画面だけだった。漏れないように作ってあることと、本人がそれを知っていることは別で、後者は実装ではなく画面でしか渡せない。

ここで方向を二つに分けて考えた。撮影日時みたいなメタを「隠す(送らない)」方向か、送るものはそのままに「何が出るかを明示する」方向か。綴った気持ちを後で web 側で時系列に並べるには撮影日時が要る。だから隠す方には倒さず、明示する側で担保すると決めた。同期の挙動そのものは一切変えない、が前提になった。

やることは、写真を web に出す「選ぶ」ボタン(この記事では公開ボタン / publish とも呼ぶ、同じ操作)を押した初回に、出るもの・出ないものを並べた確認シートを一度だけ挟む。「続ける」で従来通り公開、「やめておく」で何もしない。それだけ。なお公開まわりの導線はまだ開発ビルド限定で、本番のログイン導線が通ったところで一緒に開く(この記事は実装の置き場所の話)。

見せる場所は初回の確認シートと、設定の常設ページの2つにした。オンボーディングに足すのは外した。同期を使わない人にとっては、最初に読まされる意味のない画面が1枚増えるだけだから。かわりに設定へ同じ内容をいつでも開ける行として置いた。あとから見返す口が常にあるぶん、確認の場は初回の一度に絞れる。

そのうえで、設定から見ただけでは「確認済み」にしない。「読んだ」と「これを公開すると決めた」は別のことで、後者は公開しようとしているその瞬間にしか成立しないと考えた。だから設定側のページは閉じてもフラグに触らず、初回の確認は publish の文脈で必ず一度踏ませる。設定を開いた後に公開してシートが出るのは、記録し忘れているのではなく仕様。

どこに挟むか — コアは触りたくなかった

問題は置き場所だった。公開処理は SyncCoordinator という調整役に集約してある。

つまり SyncCoordinator は「順序と鮮度を絶対に間違えない」ことに責任を持つ場所だ。ここに「初回だけシートを出す」みたいな UI の都合を混ぜたくなかった。確認シートは画面の話であって、送信手順の正しさとは無関係だから。

なので coordinator には指一本触れず、UI 層(詳細画面で公開ボタンを押したときに走る処理)の入り口にゲートを1個置く方針にした。

戻り値でゲートする関数

丸で囲った3つだけを足した。右のコア(SyncCoordinator)には指一本触れていない。UI 層(詳細画面)公開ボタンゲート関数(足したのはここ)① もう「続ける」を押した? → 素通り② まだなら確認シートを出す③ 続ける=記録して通す/やめる=通さないtruepublishOne(コア)ここは1行も変えていないfalse何もしないそれぞれが書き込む先(別々の場所にある)SharedPreferences「確認済み」フラグ 1つだけ端末に閉じる = 紡Book へは送られない機種を変えたら、もう一度シートが出るDB(端末の中の SQLite)綴りと、写真ごとの公開フラグ同期される = 紡Book へ運ばれる公開フラグを立てるのは publishOne だけ分け方の決まり ── 同期されるものは DB、その端末にだけ閉じるものは SharedPreferences。DB は将来ほかの端末へ運ぶ前提なので、端末固有のものを混ぜると運んだ先でゴミになる。

「確認済み」の記録先を DB でなく SharedPreferences にしたのは、この分け方に従っただけだ。同期されるものは DB、その端末にだけ閉じるものは SharedPreferences。ローカルの記録はローカルに置く、というだけの話で、機種を変えたらシートがもう一度出るのは、そう決めたというよりそこから出てくる帰結になる。

置いたのはこれだけの関数。コードは AI に書いてもらっている。自分が決めたのは、置き場所をコアでなく UI 層にすることと、返り値を bool 1個に絞ることの2点だ。

dart
/// 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 を呼ぶ本体)は元のまま。

dart
Future<void> publishMemory(MemoryItem item) async {
  final accepted = await ensureSyncContentNoticeAccepted(   // ← ゲートを通す
    context: context,
    flag: ref.read(syncContentNoticeFlagProvider),          // 記録係を取り出して渡す
  );
  if (!accepted || !mounted) return;              // やめておく=ここで打ち切り
  // ...以下、従来どおり publishOne を呼ぶ(=ここから先は一切変えていない)
}

書いてから気付いたのは、「やめておく」で何も起きないことが構造的に保証されている点だった。ユーザーが「選ぶ」を押して公開フラグを立てる経路は SyncCoordinator.publishOne の中の一行しかない(この記事のあと、公開の取り消しに失敗したときの巻き戻しと、サーバの公開状態との突き合わせが別経路として増えた。どちらも「押して選ぶ」操作ではないので、以下の話は変わらない)。コードの中で1か所にしか書かれていない処理は、その外側の入れ物を呼ばない限り絶対に動かない。

dart
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 に差して押すだけ。

dart
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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。