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

モデルを書き換える前に、表示を変えてみる — ゲージを『進捗』から『現在地』に

Dart設計状態機械綴Photo

綴Photo の「綴る画面」の進捗ゲージが世界観と合わなくなった時、実装をどこまで動かして解決したかのメモです。前日に書いた写真1枚に何を残したか — 状態を「集合」で持って union で数えるの続きで、同じ TsuzuruSessionCursor を今度は表示の側から見ています。

出発点だった違和感

綴Photo の綴る画面には上バーに 決定済み / 全体 のカウンター(例: 47 / 118)と、それを埋めていく進捗バーが乗っている。「綴る」か「また今度」で決めるたびに数字が増えて、バーが伸びていく。

このカウンターは、最初からこの形だったわけではない。もとは cursor の index をそのまま出す「現在地 / 全体」だった。決定済み数に切り替えたのは、前後移動(見返し)を入れた頃だ。自由に戻れるようにすると、戻るたびにゲージの数字が減る。「進捗」のつもりで見ているバーが巻き戻るのは変なので、戻っても減らない数=決定済み数に差し替えた。当時つけたコメントにも「見返しで戻っても減らない」と書いてある。「ゲージは進捗を表すもの」という前提の下では、この修正は正しい。

これに気付いたのは、「また今度」した写真だけを本棚(後回しにした写真が溜まっていく場所)から見返す導線を作ったときだった。本棚から入り直すと写真が全部「また今度」の決定済み状態で並ぶ。つまりゲージは最初から 115 / 115 で満タン。実害はないけど、何も達成していない画面に達成表示が出ているのは変だ。

そのとき自分で口にした一言が、この記事のきっかけになった:

「もうこうなってくるとゲージがいらない気がする」

一旦は「本棚モードのときだけゲージを非表示に」の分岐を入れて済ませた。動くには動く。でも呑み込みきれなかった。

二段階の違和感

入れた分岐を眺め直したら、話は本棚モードだけではなかった。通常モードのゲージも、そもそも「進捗」の意味付けで大丈夫かという違和感がじわっとあった。

「あと 71 枚綴らないと満タン」の圧をかけたいわけじゃない。書きたい時に書けばいい、というスタンスの世界観だ。「決定済み / 全体」ゲージは、意図せずその世界観に反するメタファーになっていた。

そこで別の意味付けを口に出してみた:

「読み込んだファイルの中身、つまり全部で何枚の画像があり、現在何枚目を見ているか」の方が適切だと思ってて。進捗のゲージというよりも「現在地のゲージ」として扱うことで、また今度した写真を見るときでも同じ概念を適用できる。ロジックを一つに統一できるはずです。

「進捗」ではなく「現在地」。同じバーだけど意味が違う。しかも本棚モードと通常モードで同じ意味付けが機能する(=分岐が消える)。

分岐は「表示」か「モデル」か

ここで実装の分岐点があった。同じ「現在地」の意味付けにたどり着くとして、どこまで動かすか。実装は AI と進めているので、コードを書く手数の差より「どちらの意味付けで行くか」の判断のほうが自分の仕事になる。選択肢は二つだった:

「モデル」はアプリが裏側で持っているデータの持ち方の決めごとで、ここでは「写真をどういう順で並べて、どこまで決めたかをどう数えるか」がそれにあたる。

紛らわしいので先に言っておくと、この並びはデータベースに入っていない。保存されているのは「何をしたか」の事実だけで、並びも「今が何番目か」も、画面を開くたびに作り直している。

同じ1枚について、どこが何を覚えているかDB(閉じても残る)Photos写真の住所(assetId / localPath)Meanings綴った本文 body / 気分 moodPassedPhotosまた今度した写真開くたび組み直すメモリ(開いている間だけ)写真の並び起動のたびに端末をスキャンして作る今が何番目か← ゲージが読むのはこれ決定済みの3集合左の3つから組み直したコピー「決定済み」という列は DB に無い ── 綴り行やまた今度の行が在るかから、毎回組み直している「今が何番目か」はどこにも保存されていない ── だから表示だけで変えられた

つまりゲージが読んでいる番号は、どこにも保存されていない値だった。A と B の差は保存先の話ではなく、この並びを縮めるかどうかになる。

A. 並びから外す(モデルを書き換える) — 綴った写真をリストから消す。カウントは「今何枚目 / 残り枚数」で自然に減っていく。モデルの意味付けが「未処理写真の集合」に変わる。

B. 並びは変えず、数え方だけ変える(表示だけ書き換える) — リストは immutable(一度作ったら中身を書き換えない)のまま。カウンターを 決定済み / 全体 から 今何枚目 / 全体 に読み替えるだけ。

セッション中、写真の並びはアプリのメモリに載っているいま済済今決定済み 2 / 全体 5=いまのゲージA案リストから外す外す外す今1 / 3戻って見返せなくなるB案並びは触らない済済今3 / 5戻れる(こっちを採った)A でも B でも、DB のテーブルは1枚も増えない ── どちらもこの並びの中だけの話

A のほうがモデルとしては綺麗に閉じる。「未処理」という概念が集合そのもので表現される。でも綴った写真がリストから消えるので、戻って眺め直す(前セッションで作った「見返し」機能)が壊れる。前セッションで「決定済みも残す」判断をわざわざしたのに、今日それを覆すことになる。B は集合モデルに一切触らず、frontier や allProcessed は AllDone 判定など内部の判定用にそのまま残る。

5分で B を試す

B は実装量が少ない。cursor モデルに触らないから、frontier の遷移や AllDone 判定を固定した cursor のテスト(22本)は1行も書き換わらない。書き換わったのは写真ステージの widget テストで props 名を decidedCount → currentIndex に置き換えた2箇所だけで、何を検証しているかは動かない。

やることは3つだけだった。mixin は複数の画面で使い回す共通処理のまとめ役、props は親の画面から子の部品へ手渡す値のこと。

値は左から右へ流れる。丸の付いた3か所だけを書き換えた。cursor の index中で持っている番号1mixincurrentIndex2写真ステージ受け取る名前33 / 51中の番号を外から読めるようにする入口を1つ足す2渡す値の名前を「決定済みの枚数」から「今が何番目か」へ3表示とバーの式を「今が何番目 ÷ 全体」に書き換える(0 から数えるので +1)触っていないもの: 写真の並びそのもの(集合モデル)/ その22本のテスト=「今までの動きが守られている」の保証が切れなかった理由
dart
// mixin
// => は「この値を返す」の短縮形。内部の index をそのまま外に見せるだけ
int get currentIndex => _cursor.index;
 
// 写真ステージのカウンター
// ${...} は文字列に値を埋め込む書き方。+1 で 0 始まりの番号を「何枚目」に直す
Text('${widget.currentIndex + 1} / ${widget.totalCount}'),
 
// 進捗バー
LinearProgressIndicator(
  value: widget.totalCount == 0  // 写真が0枚のときは割り算ができないので
      ? 0                        // その場合はバーを空(0)にする
      : (widget.currentIndex + 1) / widget.totalCount,  // 通常は「何枚目 ÷ 全体」= 0.0〜1.0
),

これでカウンターは「今何枚目 / 全体」の意味になり、バーは前の写真に戻れば縮み、次に進めば伸びる(desktop なら ← → キーでの前後移動)ようになる。戻ると数字が減る——これは前後移動を入れた時に「おかしい」と判断して潰した、当の挙動だ。「進捗」を捨てて「現在地」にした今は、戻ったら減るのが正しい。同じ挙動が、意味付け次第でバグにも仕様にもなる。本棚モードで入っても意味が通る(本棚モードは特別扱いしなくて済む)。ついでに「本棚モードだけゲージ非表示」の分岐(さっき入れたばかりの showProgress props)もそのまま撤去できて、モード分岐が UI から消えた。

コード生成は AI に頼んで、確認 → 実機起動 → hot restart(アプリを入れ直さずに変更を反映させる Flutter の再読み込み)で数分。全 63 テストが緑=自動チェックは全部通り、既存の動きが壊れた箇所(回帰)もなし。

モデルは静かなまま

B の実装で気付いた副産物として、モデルは意味付けの変更にも動かないほうが得というのがあった。

frontier は「進捗」時代の名残りの単語だけど、allProcessed(写真を全部決め終わったかの判定)などの内部ロジックからは今も使われている。これが成立するとアプリは「AllDone」=全部終わった状態の画面に切り替わる(アプリが今どの状態にいるかを管理するこの仕組みを、状態機械と呼ぶ)。UI が「現在地」に変わったからといって、これらを消す必要はない。集合モデルは AllDone 判定と復元経路に効いている裏方として、そのまま静かに動き続けている。

もし A で「未処理の集合」に書き換えていたら、frontier の意味も揺れる。集合の内容自体が変わるので、allProcessed の意味も変わる(「未処理集合が空か」の判定に変質する)。AllDone 判定を書き換える → 復元経路(アプリを閉じて開き直したときに、前回どこまで見ていたかを戻す処理)も書き換える → テストも書き換える → となる。モデルの意味が動くとテストも道連れになる、というのが A を採る前に見えた。

B が短時間で済んだのは、モデルを触らなかったからテストの中身が1つも書き換わらなかったから、という側面が大きい。props 名の追従で2箇所は直したけれど、それは機械的な置換で、アサーションは1つも触っていない。

学んだこと

  • 概念の再定義は、まず表示から試す。集合の意味を変えるより、集合はそのままにして UI の意味付けだけ書き換えるほうが軽い。A なら集合モデルとテストまで波及していたところが、5分で済んだ。
  • モデルが意味の変更に動かないと、テストの「中身」も動かない。名前の追従は出ても、アサーションが1つも書き換わらないこと自体が、既存の挙動を壊していない証拠になる。
  • UI 層の「意味付け」と、モデル層の「集合の内容」は別レイヤーとして運用できる。同じ集合を「進捗」として見せる時期と「現在地」として見せる時期があってよくて、モデル側を毎回動かさなくていい。

いちばん効いたのは、前提のほうだった

「戻ると数字が減る」を潰して決定済み数に差し替えたのも、「本棚モードだけゲージ非表示」の分岐を足したのも、どちらも「ゲージ=進捗」を守るための出費だった。個別には全部妥当で、だから前提を疑う機会がないまま溜まる。

前提を守るための後始末が増えてきたら、前提のほうを疑う。 前提を1つ捨てたら、両方の対処ごと消えた。

短時間の実装だったけど、「モデルを書き換える/書き換えない」の分岐で毎回同じ判断が必要になる気がした。次に「概念を変えたい」と思ったら、まず表示だけで試して、それでは済まない時にモデルを動かす順序を意識したい。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。