綴Photo の「綴る画面」の進捗ゲージが世界観と合わなくなった時、実装をどこまで動かして解決したかのメモです。前日に書いた写真1枚に何を残したか — 状態を「集合」で持って union で数えるの続きで、同じ
TsuzuruSessionCursorを今度は表示の側から見ています。
出発点だった違和感
綴Photo の綴る画面には上バーに 決定済み / 全体 のカウンター(例: 47 / 118)と、それを埋めていく進捗バーが乗っている。「綴る」か「また今度」で決めるたびに数字が増えて、バーが伸びていく。
このカウンターは、最初からこの形だったわけではない。もとは cursor の index をそのまま出す「現在地 / 全体」だった。決定済み数に切り替えたのは、前後移動(見返し)を入れた頃だ。自由に戻れるようにすると、戻るたびにゲージの数字が減る。「進捗」のつもりで見ているバーが巻き戻るのは変なので、戻っても減らない数=決定済み数に差し替えた。当時つけたコメントにも「見返しで戻っても減らない」と書いてある。「ゲージは進捗を表すもの」という前提の下では、この修正は正しい。
これに気付いたのは、「また今度」した写真だけを本棚(後回しにした写真が溜まっていく場所)から見返す導線を作ったときだった。本棚から入り直すと写真が全部「また今度」の決定済み状態で並ぶ。つまりゲージは最初から 115 / 115 で満タン。実害はないけど、何も達成していない画面に達成表示が出ているのは変だ。
そのとき自分で口にした一言が、この記事のきっかけになった:
「もうこうなってくるとゲージがいらない気がする」
一旦は「本棚モードのときだけゲージを非表示に」の分岐を入れて済ませた。動くには動く。でも呑み込みきれなかった。
二段階の違和感
入れた分岐を眺め直したら、話は本棚モードだけではなかった。通常モードのゲージも、そもそも「進捗」の意味付けで大丈夫かという違和感がじわっとあった。
「あと 71 枚綴らないと満タン」の圧をかけたいわけじゃない。書きたい時に書けばいい、というスタンスの世界観だ。「決定済み / 全体」ゲージは、意図せずその世界観に反するメタファーになっていた。
そこで別の意味付けを口に出してみた:
「読み込んだファイルの中身、つまり全部で何枚の画像があり、現在何枚目を見ているか」の方が適切だと思ってて。進捗のゲージというよりも「現在地のゲージ」として扱うことで、また今度した写真を見るときでも同じ概念を適用できる。ロジックを一つに統一できるはずです。
「進捗」ではなく「現在地」。同じバーだけど意味が違う。しかも本棚モードと通常モードで同じ意味付けが機能する(=分岐が消える)。
分岐は「表示」か「モデル」か
ここで実装の分岐点があった。同じ「現在地」の意味付けにたどり着くとして、どこまで動かすか。実装は AI と進めているので、コードを書く手数の差より「どちらの意味付けで行くか」の判断のほうが自分の仕事になる。選択肢は二つだった:
「モデル」はアプリが裏側で持っているデータの持ち方の決めごとで、ここでは「写真をどういう順で並べて、どこまで決めたかをどう数えるか」がそれにあたる。
紛らわしいので先に言っておくと、この並びはデータベースに入っていない。保存されているのは「何をしたか」の事実だけで、並びも「今が何番目か」も、画面を開くたびに作り直している。
つまりゲージが読んでいる番号は、どこにも保存されていない値だった。A と B の差は保存先の話ではなく、この並びを縮めるかどうかになる。
A. 並びから外す(モデルを書き換える) — 綴った写真をリストから消す。カウントは「今何枚目 / 残り枚数」で自然に減っていく。モデルの意味付けが「未処理写真の集合」に変わる。
B. 並びは変えず、数え方だけ変える(表示だけ書き換える) — リストは immutable(一度作ったら中身を書き換えない)のまま。カウンターを 決定済み / 全体 から 今何枚目 / 全体 に読み替えるだけ。
A のほうがモデルとしては綺麗に閉じる。「未処理」という概念が集合そのもので表現される。でも綴った写真がリストから消えるので、戻って眺め直す(前セッションで作った「見返し」機能)が壊れる。前セッションで「決定済みも残す」判断をわざわざしたのに、今日それを覆すことになる。B は集合モデルに一切触らず、frontier や allProcessed は AllDone 判定など内部の判定用にそのまま残る。
5分で B を試す
B は実装量が少ない。cursor モデルに触らないから、frontier の遷移や AllDone 判定を固定した cursor のテスト(22本)は1行も書き換わらない。書き換わったのは写真ステージの widget テストで props 名を decidedCount → currentIndex に置き換えた2箇所だけで、何を検証しているかは動かない。
やることは3つだけだった。mixin は複数の画面で使い回す共通処理のまとめ役、props は親の画面から子の部品へ手渡す値のこと。
// 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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。