- lightImpact + 160 ms confirm cue on a validated log (audioplayers,
no audio focus); device-local opt-out switch in Settings, on by default
- post-log card: confirmation line (slot + the meal's kcal), entrance and
kcal/streak count-up on motion tokens transcribed into ui/theme.dart;
durations collapse to zero under disableAnimations
- test/flutter_test_config.dart mutes the cue suite-wide
Todo: 8e66c1b9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVHMLzeojNP4AaPoEj
Swap google_sign_in for the FOSS AccountManager plugin
(google_account_token-v0.1.0); drop the local platform gate. Also
fix an unmatched coverage:ignore pair that broke --coverage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
Fires on the tap before the write; rejected logs stay silent. The
dopamine-ux TODO now lists only the remaining work.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVUTaKFW8m7YrvWbzV
Body: 2-column tiles, each opens its card in a sheet. Food bank: paged
3-column tiles, tap for macros. Pixel 6a widget test asserts no vertical
scroll on log, calendar, food bank (250 foods) and body.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVUTaKFW8m7YrvWbzV
Fill all with catering always fetched and filled today, so picking 24.10
on 23.10 reported "No catering delivery today." FillAllFlow.tap now takes
the picked day: fetch, slot schedule, occupancy and dedup all resolve
against it, and logDishes stamps each dish at its slot time on that day.
- A plan only confirms onto the day it was proposed for; changing the
date (or back to today) disarms it.
- A later-day fill leaves today's delivery queue and prefill alone and
returns the form to today, as a manual "Log for later" does.
- Only today's fetch records the single-day "already fetched" marker;
recording a later date would skip that day's automatic fetch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwYgigocPkPWa73QZRtU38
The dropdown menu opens centred on the current value, so a "Custom…" item
at either end of ~95 entries was always a long scroll away. Each meal-time
field now has a clock IconButton ("Type or pick an exact time") opening the
same 24h showTimePicker with the same clamping. The off-grid current value
is still offered as an item.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
At the end of the list it sat ~95 grid items down; now it is the first
item, above 00:00. Test pins the order.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
Pins that fm/lm survive both the sync wire and a disk reload, and that a
quarter-hour pick in SettingsScreen goes through the real _applySchedule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
- FoodEntry.slot -> slotMinute: read via entrySlotMinute, written via
slotFields (whole-hour stays byte-identical, else slot_min), kept by
copyWithDeleted and the edit screen, so the phone never republishes a
stripped copy of a PC entry. Out-of-range peer values echo, never throw.
- Satisfaction is nearest-slot snapping (satisfiedSlots) everywhere:
loggedSlotsToday, due check, kuchnia occupancy, future-day logged set
(that day's schedule), slot selector. Schedule edits no longer un-log.
- Future pre-log builds DateTime from slot ~/ 60, slot % 60.
- Schedule history + sync go through scheduleToWire/scheduleFromWire.
- Notifications: ids are slot minutes; syncToSlots cancels whatever the
platform reports active (getActiveNotifications, reminder channel) that
is not due, covering legacy hour ids; full 0..1439 sweep only if the
lookup throws.
- Settings: 15-minute dropdowns + "Custom..." (24h showTimePicker), off-grid
values always selectable, counts capped by grid capacity; editor moved
to widgets/meal_schedule_editor.dart + meal_time_dropdown.dart.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
Meal-slot checkpoints move from whole hours to minute-of-day ints
(0..1439) in the pure model and slot arithmetic of both languages:
MealSchedule(first_minute, last_minute, count) / (firstMinute:,
lastMinute:, count:), interior slots snapped to the 15-minute grid,
endpoints exact, integer-only. New: nearest_slot, satisfied_slots,
slot_fields / entry_slot_minute (additive "slot_min" wire field) and
schedule_to_wire / schedule_from_wire ("fm"/"lm" only when off the hour,
so whole-hour data stays byte-identical), parse_hhmm.
tests/fixtures/meal_schedule_vectors.json (scripts/
build_meal_schedule_fixture.py) is read by both suites; kuchnia_day.json
slot values regenerated as minutes.
Consumers (CLI, gate, storage, sync, app screens, kuchnia, notifications)
are intentionally broken until phase 2. patch-targets skipped: it fails
only because diet_guard._gatelock no longer imports.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
Also bumps Flutter 3.47.3, flutter_secure_storage 11.1.0, idb_shim 2.9.9,
mcp 2.2.0 and ruff 0.16.7 so the freshness gate lets this through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
`fillControllersFromDish` set the dish's name, four macros and `grams` but
left `perGrams` untouched. A `100` left there by an earlier food-bank pick
made `nutritionForPortion` read the dish's per-portion macros as per-100g and
rescale them: one real entry on 2026-08-23 logged 472 kcal as 1217.8 (258/100).
The PC never had this -- `_gatelock_delivery.py` calls `_clear_inputs()` first
-- so the fix is that same discipline, as one `MacroControllers.clear()` call
rather than a `perGrams.clear()` line a seventh field could forget. `fillFrom`
now clears too, and `edit_entry_screen` routes through it instead of six
hand-written assignments; its `initState` fill had the same shape.
The old tests asserted controller *text* and passed throughout the bug, so the
new ones assert the computed `Nutrition` -- verified failing (400 -> 1200.0)
before the fix. `clear()` gets an enumerated test so a new field fails until
it is cleared too.
Also adds the log screen's "Today's delivery" button, matching the PC lock
screen's affordance: explicit taps are unguarded per the Triggers table, still
skip dishes already logged, and show the queue count. The whole action row
moved to its own widget, which put `log_meal_screen.dart` back under the cap.
The label is a FittedBox in an Expanded -- measured, since a Row clips an
oversized label without ever throwing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
`KuchniaQueueService` had no `init()` call in `main()`, so on a real device
the whole feature would have done nothing: every entry point checks
`isInitialized` first and returns quietly, which reads as "no delivery today"
rather than as an error. All 694 tests passed throughout, because widget tests
initialise the service directly.
Caught by grepping main.dart before the phone deploy, not by the suite. So it
is now a repo invariant: `repo_invariants_test.dart` asserts main() names both
catering `init()` calls, and the assertion was checked against a main.dart
with the line deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The phone now prefills the log form with today's delivered dishes, in the
caterer's own meal order. It still never logs anything itself -- a delivered
meal is not an eaten meal, so the user taps for each dish.
Two guards carried over from the PC, both of which fail silently if dropped:
- **The queue survives a submit.** `prefillNextDish` has two callers: the
initial load and the post-submit advance. On the PC the second briefly did
not exist, which made "(N more to go)" a dead letter -- every dish after the
first waited behind another tap. The test asserts the *form controller*
holds dish 2 after dish 1 is logged, not that a dish is "available"; the
latter passes throughout that bug.
- **The automatic refresh is guarded** by a device-local last-fetched date,
mirroring `refresh_delivery_once`. The log-flow trigger can fire after every
meal and each unguarded walk is a login plus three requests. The settings
button deliberately stays unguarded: an explicit ask always goes and looks,
matching `diet-guard kuchnia`. Only a clean fetch records the day, so an
outage retries.
Dishes are ordered against `MealScheduleService.current`, never a hardcoded
four slots -- offering a dish for a slot the user's schedule does not have is
the "checkpoint that can never be satisfied" failure in a new costume.
Editing the description before submitting leaves the dish queued: what was
logged was not the delivered dish, and marking it eaten would lose a real
delivery silently.
Two bugs the tests caught:
- `dishesStillQueued` excluded the offered dish, so the status line read
"0 more" while a real dish sat in the form. The offered dish is prefilled,
not logged; it counts.
- An end-to-end test through the real `LogMealScreen` never terminated --
that screen's `initState` fans out into sync, notifications and the food
bank, and pumping it to quiescence hangs. Rather than keep fighting it, the
behaviour is tested at the mixin that owns it, through a minimal host. The
guarantee asserted is the same one: form contents after a submit.
`log_meal_screen.dart` is back under the 250-line cap by moving the nutrition
build into `log_meal_actions.dart` and inlining a one-line alias.
694 Dart tests, 1040 Python tests, 100% Python branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The credential **fields** appear on every platform; the **fetch button** only
where it can work. That split is the point, not an oversight: the panel sends
no CORS headers so a browser cannot call it, but the desktop app is where a
password is most likely to be typed, and the credential syncs — so hiding the
fields on web would mean the phone could only ever be set up by typing on the
phone. On web the button is replaced by a line saying why, and noting the
login still reaches the phone from there.
The section says the password is stored and synced in plain text rather than
implying otherwise.
The button disables itself while a fetch is in flight, so an impatient
double-tap cannot pay a second login plus three requests against a third
party. The result line says dishes were added to the **food bank** and that
the user still logs them when eaten — the wording carries the
delivered-is-not-eaten rule to the one place a user actually reads.
`settings_screen.dart` was already at 247 of its 250-line budget, so the
section owns its own leading divider and the fetch handler lives beside it;
the screen spends one line on it.
678 Dart tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The phone now walks the panel on its own -- login, active order, delivery,
menu -- parses the dishes and banks them, so the feature works with the PC
switched off. Read-only: nothing here logs a meal.
`package:http` has no cookie jar, so the `SESSION` cookie is read off the login
response and replayed by hand. `Response.headers` folds duplicate `set-cookie`
values into one comma-joined string, so the parser splits on both `,` and `;`
and looks for the named cookie rather than assuming the header holds only it --
tested against both orderings and against a panel that sets no session cookie
at all.
Android-only, as decided: the panel sends no CORS headers, so a browser cannot
call it. `refreshDelivery` checks `kIsWeb` first and returns the unsupported
reason without touching the network. No `dart:io` anywhere -- `package:http`
compiles for web, it is the panel that refuses, not the toolchain, so this
needs no `_io`/`_web` seam.
The idempotency guard is ported with its teeth: `addManualEntry` restamps
`editedAt` unconditionally, so re-banking an unchanged dish republishes the
whole curated bank to every peer. `bankDishes` compares the nutritional fields
first and returns a write *count*, which is what the tests assert on -- "the
entry exists" passes while this misbehaves. The comparison is numeric, not
string, so `270 == 270.0` is a match exactly as in Python's `_matches`.
Every failure becomes a reason string, never a throw, so a catering outage
cannot break the caller: a rejected login, a delivery whose dishes all fail
the unit check, and missing credentials each have a test.
One test bug worth noting: the assertion that nothing is logged was missing an
`await` and so was comparing a Future against `isEmpty` -- the single most
important guarantee in this file was passing vacuously until the split forced
it to run.
Test files split three ways for the 250-line cap; block counts checked against
the pre-split file (16 either side), not just a green suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The Dart half of the credential sync: a `KuchniaCredentialService` that owns
the value locally, a `sync_merge_kuchnia.dart` adapter mirroring the Python
one, and a `_syncKuchniaCredential` tick alongside the banks and budget. The
phone can now receive the panel password, which is the prerequisite for it
fetching anything itself.
One source of truth, per the answered question: the value lives in the synced
document store and nowhere else. Deliberately *not* mirrored into
`flutter_secure_storage` -- two homes for one value is how a stale password
survives a change made on the other device, and the credential has to be
readable synchronously at merge time anyway.
Gated by a second shared fixture, `tests/fixtures/kuchnia_credential.json`,
asserted by both suites. Two cases carry the weight:
- an **unparsable** edit time must fall back to the same clock on both sides
(Python catches `ValueError` from `fromisoformat`, Dart's `tryParse` returns
null). Verified they agree on `wall_time_ms == 0`; mutating that expectation
fails both suites.
- a **blank half** contributes nothing, so a half-filled settings form cannot
push an empty-string password that wins LWW against a real one.
`KuchniaCredentialService.init()` is called in the WorkManager isolate as well
as `main()`. A fresh isolate has its own static state and `_syncKuchniaCredential`
no-ops when the singleton is missing, so without it a background tick would
neither publish this device's credential nor receive a peer's -- and on the
phone the background tick is most of the syncing there is. Same class of trap
as the `initSyncDeviceId()` one already documented there.
`.toDouble()` came off the bank record: the fields are already `double`, so it
was a no-op the analyzer flags. The field *types* are what keep the encoding
byte-identical to Python's, and `kuchnia_parity_test.dart` asserts that.
654 Dart tests, 1040 Python tests, 100% Python branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
The phone is getting its own Kuchnia Wikinga importer so the feature works
with the PC switched off. That turns a risk `_kuchnia_spread.py` called
"theoretical" into a live one: two implementations must now agree exactly on
which dishes are dropped, which slot each lands on, and the JSON a banked
record encodes to. Disagreement means each device re-adds what the other
dropped, and since `add_manual_entry` restamps `t` unconditionally, the whole
curated bank republishes to every peer on every refresh. A slot mismatch is
worse -- a checkpoint one device offers and the other does not can never be
satisfied.
So the port lands with its gate rather than after it. One shared fixture,
`tests/fixtures/kuchnia_day.json`, is asserted by both `pytest` and
`flutter test`: same input, same expected dishes, drops, slots, bank keys and
records. Two suites written independently from the same prose is not a gate.
The fixture is five real dishes (a scrubbed 2026-08-22 capture) plus synthetic
meals that each pin one drop or fallback path -- a per-100 g mix-up, an absurd
portion, a stringly-typed number, a non-dict entry, a missing `nutrition`, a
missing `mealPriority`, and a twin-dish pair. Without those, "the same meals
are dropped" is a vacuous assertion against an all-happy-path capture. Both
halves were mutation-checked: flipping an expected slot, drop count or record
value fails each suite, and flipping `435.0` to `435` fails the byte-identity
test while the value comparison still passes, which is why both exist.
Three traps the port had to survive, each caught before it shipped:
- `_ENERGY_TOLERANCE` is **0.35**. The prose said "~1%", which describes how
closely the captured day agreed, not the threshold; porting 0.01 would drop
dishes the PC keeps. That paragraph now says so.
- `jsonEncode(435)` emits `435` where Python emits `435.0`, so every Dart
macro is forced to `double` and a canonical-encoding test pins it.
- Dart's `List.sort` is unstable where Python's `sorted` is stable, so the
Dart comparator carries the payload index as a final tiebreak.
The real dishes live in their own file rather than as a literal in the
generator: transcribing them by hand corrupted three of the five, and the
error was invisible until the generated fixture was diffed against the
capture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
Walking a real 5-dish delivery through the gate by hand showed the
"(N more to go)" the lock promises was a dead letter: `_prefill_next_dish`
had exactly one caller (the initial poll), so logging the first dish cleared
the form and stranded the rest. Reaching them meant clicking "Today's
delivery" again -- and the button's fetch is unguarded, so that was a fresh
login-plus-three-request walk per dish.
`_finish_slot` now prefills the next queued dish, carrying the
"Logged HH:00 ..." confirmation into the same status line so advancing the
queue does not swallow it.
The caterer plans five meals against four default slots, so a dish is still
queued when the last slot unlocks. It is already banked, so `_unlock` names
it rather than dropping it silently.
Also pins the phone conclusion that was previously reasoning only: a verbatim
capture of a real imported bank now round-trips through `FoodBankRecord` in
`app/test/kuchnia_bank_interop_test.dart`, covering the diacritics and the
`t` stamp, and the casefold/toLowerCase agreement the two devices depend on.
Verified against the live panel with state redirected to a scratch HOME
(real ~/.local/share/diet_guard byte-identical before and after): the
autoload prefills silently on window open, a failed autoload stays silent
while the button reports, the after-log warm-up runs off-thread (0.62s vs
0.28s guarded) and nothing is logged until "Log & Continue".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
Found on the phone, not in the suite. Setting five meals updated the settings
preview and fired a reminder for the new 14:00 checkpoint, while the slot row
behind them kept showing the old 08/12/16/20 until the app was restarted:
onOpenSettings pushed the route without awaiting the pop, so the log screen
never rebuilt against the schedule the user had just chosen.
It now awaits and calls onScheduleMayHaveChanged, which re-reads the logged
slots and re-derives the selected slot -- the hour it pointed at may not be a
checkpoint at all any more. The other three destinations still fire and
forget, since none of them can change what this screen renders.
The regression test was confirmed to fail against the pre-fix behaviour
("Found 0 widgets with text 11:00") rather than passing vacuously.
Both files crossed the 250-line cap with the addition, so the hook moved to
log_meal_sync_mixin.dart -- which already owns refreshSlots and loggedSlots
and is the better home regardless -- and the new test into its own file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
The first edit surface, landing after the sync layer so a schedule set here
reaches the PC rather than leaving the two devices deriving different slots.
Three dropdowns -- first meal, last meal, meals per day -- and a live preview
of the checkpoints they derive. Setting 08:00, 20:00 and 5 shows
08:00 · 11:00 · 14:00 · 17:00 · 20:00, which is asserted in the screen test
rather than only in the model's.
Dropdowns rather than showTimePicker: that returns a TimeOfDay with minutes,
and slots are whole hours on both devices by design. They also make the
invalid states unrepresentable instead of validated -- "last meal" offers only
hours after "first", and "meals per day" only counts the window has whole
hours for. MealSchedule.normalized() still clamps, but that is the defence
against corrupt synced data, not the input contract.
Saving re-runs the due-slot check, because reminder ids are slot hours: a
schedule change orphans the ids it no longer contains, and waiting for the
next background tick would leave a stale nag on screen for up to 15 minutes.
The UI is a `part` mixin like settings_kcal_goal.dart -- settings_screen.dart
was at 238 of the 250-line cap and is now at 247.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
Without this the schedule would be device-local, which is the failure this
whole feature has to avoid: the phone sets five meals, the PC still derives
four, and the PC's gate nags for a 12:00 checkpoint the phone never offered
and can never satisfy. So sync lands before either edit surface, not after.
The history rides the existing `budget` CRDT record as one
`sched:<YYYY-MM-DD>` field per entry, exactly like the budget's own `hist:`
fields. merge_record is per-field LWW over the *union* of field names and both
devices push the merged record, so a device that predates the feature relays
the fields untouched -- no coordinated release. There is a test for precisely
that: merging our push with a budget-only peer keeps both schedule entries.
Verified the two languages agree on the wire, not just in their own suites --
Python and Dart both emit
{"sched:1970-01-01":{"f":8,"l":20,"n":4},"sched:2026-08-16":{"f":8,"l":20,"n":5}}
The Hlc is derived from the *parsed* timestamp on both sides, which matters
because the epoch fallback formats differently in each language
(1970-01-01T00:00:00+00:00 vs ...000Z) and a string comparison would diverge.
Two guards worth naming:
- A device with no history contributes no `sched:` fields at all, so an unset
default can never outrank a peer's real schedule; and an empty merge result
is never written back, so a pre-feature peer cannot erase a real history.
- Dart's `_syncBudget` synthesises a local record when the schedule has been
edited but the budget never has -- `budgetToLog(null, ...)` returns an empty
Log and would otherwise drop the schedule fields too. Its budget value
carries an epoch stamp, so it loses every LWW race against a real edit.
sync_merge/_schedule.py and sync_merge_schedule.dart are separate modules
rather than additions to the budget adapters, which were at 186 and 170 lines
against the 250 cap. Each declares its own record-id constant so the Dart
pair does not import each other.
772 Python tests at 100% branch coverage, 619 Dart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
The phone/desktop half of the previous commit, so both devices now derive
their slots the same way. Still no edit surface, so `MealScheduleService`
returns the default and the derived hours are unchanged at 08/12/16/20.
`slot.dart` takes the schedule as a required argument, mirroring `_slots.py`,
and `slot_test.dart` gains the same every-hour x several-schedules sweep the
Python suite runs. That sweep is the contract: the two must agree on which
checkpoint a meal satisfies for every cell, not merely on the endpoints.
Also fixes a real bug this feature would otherwise have shipped on top of.
`syncToSlots` used the slot hour as the notification id but only cancelled ids
in the *current* schedule, so changing 08/12/16/20 -> 08/11/14/17/20 would
leave ids 12 and 16 posted with nothing that could ever cancel them: permanent
reminders for checkpoints that no longer exist. It now sweeps the whole 0..23
id space. The regression test was confirmed to fail against the old loop
(4 cancels vs 24) rather than passing vacuously.
MealScheduleService.current degrades to the default when the singleton is
absent, so `due_slot_check` deliberately does not init() it -- that would
reach path_provider from widget tests and from the WorkManager isolate before
main() runs. main.dart owns the real init, and background_sync_service.dart
does its own, since a fresh isolate has its own static state and would
otherwise nag against the default four meals.
609 Dart tests, 761 Python, both suites green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
First step of the configurable meal schedule: the pure arithmetic, in both
languages, with nothing wired to it yet. MealSchedule(8, 20, 5) gives
08, 11, 14, 17, 20; the default MealSchedule(8, 20, 4) reproduces the hours
that are currently hardcoded, so nothing changes behaviour until a user picks
something else.
Two properties are load-bearing and both are tested rather than asserted in
prose:
Integer arithmetic only, no floats and no round(). Python's round() is
banker's (round(2.5) == 2) and Dart's is half-away-from-zero
(2.5.round() == 3), so a floating-point derivation would silently disagree
across devices -- and a slot one device offers but the other does not is a
checkpoint that can never be satisfied, i.e. a permanent lock. The shared
vector table plus an identical 6912-case sweep in both suites is what catches
that class of bug before it reaches a device.
Counts are clamped to the window width rather than rejected. A narrow window
(08-12 across 6 meals) would otherwise repeat an hour -- 8, 9, 10, 10, 11, 12
-- and slot hours are used as set members, map keys and notification ids, so a
repeat silently drops a checkpoint. Clamping also means the two languages
cannot disagree about which inputs are errors.
The enforcement cutoff is now derived as last + 2h (clamped to midnight)
rather than sharing the constant that also bounded the slot range. The clamp
matters: 23:00 + 2 = 25 would make `hour < cutoff` vacuously true and the
window would never close.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
Three independent cleanups ahead of the configurable meal schedule.
Pills: the row was a `Wrap`, so pills dropped onto a second line as soon as
they outgrew the width -- which they do even at four. Measured: a logged chip
is ~124px at Material defaults, so four need 522px against ~330px of phone
width. Now a `FittedBox(scaleDown)` over a `Row`, which scales the row down
uniformly, plus compact chip density so there is less to scale away.
A `Row` of `Expanded` children was the obvious alternative and is wrong: it
imposes a tight width, and a Chip narrower than avatar+padding+label clips its
label rather than shrinking -- measured at 10px per label, about one character,
while throwing no overflow error. The new regression test therefore asserts
both a shared baseline and a legible label width; it was confirmed to fail on
the old `Wrap` ("chip wrapped to a second line at 320.0") rather than passing
vacuously.
Card: the `Logged "<meal>".` line restated the description the user had just
typed. Removed, along with `TodayProgress.justLogged`, which nothing else read.
The kcal position, macros and streak stay.
refactor_claude_todo.md: the 250-line file-splitting refactor it tracked is
done -- `scripts/check_file_length.sh --all` exits 0 and an independent sweep
finds nothing over the cap. DESIGN_AUDIT_TODO.md is a separate, still-open
effort and stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
Final batch of the 250-line sweep. `check_file_length.sh --all` exits 0: every
file in the repo is at or under 250 lines.
github_mirror_screen.dart 391 -> 205 (+ actions, client_id_dialog parts)
foodbank_service.dart 314 -> 209 (+ foodbank_service_manual part)
settings_screen.dart 286 -> 237 (+ settings_kcal_goal part)
firebase_backend.dart 282 -> 100 (+ firebase_client.dart)
Three of the four are `part` files rather than separate libraries, and that is
forced rather than stylistic: the moved code drives private state
(`_busy`, `_status`, the text controllers) and calls private helpers
(`_readBank`, `_normalize`), none of which are visible across a library
boundary in Dart. Widening them to public just to split a file would leak
internals the rest of the app has no business calling.
Within those parts, mixins rather than extensions, for a concrete reason: an
extension is not a subclass of `State`, so calling `setState` from one is an
`invalid_use_of_protected_member`. Fields the State must own (the controllers
it creates and disposes) stay declared there and are reached through abstract
getters.
`firebase_client.dart` is a real library split -- opening an authenticated
client has no private coupling to the keystore-backed account record, only to
its public API. The one exception, dropping the account marker without setting
the opt-out flag, became `forgetAccountMarker()` rather than exposing the
secure store.
Verified: flutter test 555 (unchanged), flutter analyze clean, 707 Python
tests / 100% branch coverage, check_patch_targets 0, pre-commit clean,
check_file_length.sh --all exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
Eleventh batch of the 250-line sweep (over-cap count 12 -> 5). Every test file
in the repo, Python and Dart, is now under cap; the five left are all in
app/lib/.
wrapper_server_test.dart 349 -> 145 (+ proxy, account siblings)
app_settings_service_test.dart 321 -> 162 (+ sync sibling)
average_service_test.dart 299 -> 158 (+ periods sibling)
settings_screen_test.dart 274 -> 189 (+ battery sibling)
day_status_service_test.dart 270 -> 161 (+ streaks sibling)
edit_entry_screen_test.dart 269 -> 159 (+ foodbank sibling)
day_status_calendar_test.dart 256 -> 237 (+ _test_support sibling)
day_status_calendar_test is a single group whose tests close over group-local
state, so it could not be split by block. Its ~25 lines of file-level fixtures
moved to `day_status_calendar_test_support.dart` instead, which required the
same publicisation as the sync support file (`_theme` -> `appTheme`, `_wrap` ->
`wrapInApp`, `_colorOfDay` -> `colorOfDay`) with doc comments.
Block counts were compared against HEAD per family rather than trusting the
suite total, since the duplication bug in the previous batch passed green at an
inflated count. All seven families match exactly (29/26/34/11/34/8/14).
Verified: flutter test 555 (unchanged), flutter analyze clean, pre-commit
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
Tenth batch of the 250-line sweep (over-cap count 18 -> 12; all Python is
already clear, so the remainder is Dart).
sync_merge_test.dart 694 -> 232 (+ 4 siblings)
github_mirror_screen_test.dart 512 -> 250 (+ 3 siblings)
foodbank_service_test.dart 395 -> 190 (+ manual sibling)
sync_service_test.dart 394 -> 203 (+ budget sibling + support)
log_storage_service_test.dart 385 -> 182 (+ edit sibling)
log_meal_screen_test.dart 362 -> 243 (+ nav sibling)
sync_service_test's ~100-line fixture block is shared through
`sync_service_test_support.dart` rather than duplicated: copying it would have
put both siblings back over the cap on their own. The helpers had to become
public (`_FakeGitHub` -> `FakeGitHub`, `_manual` -> `manualNutrition`,
`_remoteBudgetJson` -> `remoteBudgetJson`) with doc comments, since a leading
underscore is library-private in Dart and the siblings could not otherwise see
them.
The block-boundary matcher earned two fixes, both caught by comparing block
counts against HEAD rather than by the suite:
- A test name wrapped onto its own line (dart format does this past 80 cols)
was invisible to a same-line-only pattern, so the neighbouring span sliced
through it -- the file stopped compiling. Spans now end at the matching
close paren of the call, not at a guess past the closure brace.
- A double-quoted name containing an apostrophe ("skips its own device id
('phone')") was excluded by a character class that banned both quote
characters. That block fell into the shared prologue and was duplicated into
every output: the suite went GREEN at 559 tests instead of 555. Reverted and
redone; text between two blocks is now treated as a shared helper.
Verified: flutter test 555 (unchanged, and each split family's block count
matches HEAD exactly), flutter analyze clean, pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
food_bank_screen.dart (712) carried a near-clone of the filter sheet batch 2
had just factored out of history: the same name-search field, the same slider
captions, and four more copies of the same min/max macro range block. Splitting
it in isolation would have baked that duplication in permanently, so the shared
widgets moved to a neutral `lib/widgets/filters/` first and both screens now
import them.
food_bank_screen.dart 712 -> 238
lib/widgets/food_bank/
fb_filter.dart 156 FbFilter, FbSortField, applyFbFilter
fb_filter_sheet.dart 244 the sheet itself
fb_add_entry_dialog.dart 103 the manual-entry dialog
lib/widgets/history/history_filter_fields.dart
-> lib/widgets/filters/filter_fields.dart
FilterRangeRow gained `showEndpointLabels`, defaulting to true. History showed
endpoint captions and a selected-range readout; the food-bank sheet never did,
and switching them on there would have been a UI change smuggled in under a
refactor. The food-bank call sites pass false, so both screens render exactly
what they rendered before.
FbSortField and HistorySortField stay separate on purpose -- they are not the
same enum (`name` vs `date`/`description`), so the sort dropdown is not shared.
The test file mirrors the source split, three files under test/screens/
food_bank/ plus fb_test_support.dart for the setUp/tearDown/settle prologue,
same shape as batch 2's history_test_support.dart.
Verified: 555 flutter tests (unchanged count -- no test lost, and food bank's
own RangeSlider/sort-toggle tests still pass against the shared widget),
flutter analyze clean over lib/ and test/, live ~/.local/share/diet_guard
byte-identical. Over-cap files: 40 -> 38.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
history_screen.dart (1052) and history_screen_test.dart (1017) were a third of
the remaining over-cap work. Widget extraction only -- no route changes, since
routes are user-visible and repo_invariants_test.dart pins the navigation
surface.
history_screen.dart 1052 -> 211
lib/widgets/history/
history_filter.dart 102 filter criteria + sort enum (pure data)
history_grouping.dart 206 HistoryItem tree, apply/group/format
history_list.dart 194 GroupedList, DayHeaderTile, EntryTile
history_filter_sheet.dart 242 the sheet itself
history_filter_fields.dart 168 search field, slider labels, range row
history_filter_sort.dart 140 source chips + sort controls
Names that now cross a file boundary became public (HistoryItem, DayHeader,
EntryRow, buildGroupedItems, formatDay, dateRangeLabel, the tile widgets), each
with the doc comment public_member_api_docs requires -- 49 of them, written
rather than suppressed.
The four macro range sliders (kcal/protein/carbs/fat) were four near-identical
30-line blocks differing only in label, key, unit and which HistoryFilter pair
they read/write. They are now one FilterRangeRow taking min/max plus an
onChanged, which is most of how the sheet got under the cap.
The test file mirrors the source split one-for-one, six files under
test/screens/history/. Its setUp/tearDown and the settle() helper (needed by
every part, because HistoryScreen loads via an untracked initState future) moved
to history_test_support.dart rather than being copy-pasted six times.
Verified: 555 flutter tests (same count as before -- no test lost in the split),
flutter analyze clean over all of lib/ and test/, 701 python tests at 100%
branch coverage, `python -m diet_guard sync` -> 402 entries/exit 0, live
~/.local/share/diet_guard byte-identical. Over-cap files: 42 -> 40.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC