- 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
- 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
Split out from the free-day change only by sequence -- diet-guard's
pre-push CI mirror runs the freshness gate over --all-files, so these
were already blocking a push before that work started.
permission_handler 13.0.1 -> 13.0.2 and workmanager 0.10.9 -> 0.10.10
are patch bumps. very_good_analysis ^10.3.0 -> ^11.0.0 is the major one,
and it newly flags 8 async_return_with_no_await sites across the desktop
wrapper and the sync/Firebase services.
Each is now `return await`, not a suppression. The rule is right in every
one of these cases: without the await the future escapes the enclosing
async function, so a rejection surfaces at the caller with a stack trace
that no longer points at the code that made the call.
background_check_service.dart:33 is the clearest example -- its own
comment says the push "is retried with backoff rather than silently
dropped", which is only true if the failure happens inside the function.
Nothing in CI or pre-commit runs `flutter analyze`, so this would not
have failed anything. Leaving a newly-red analyzer for the next person to
find is how a repo stops being analyzable at all.
flutter analyze clean, 706 Flutter tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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
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
Twelfth batch of the 250-line sweep (over-cap count 5 -> 4).
log_meal_screen.dart 431 -> 250
Widget/behaviour extraction only, no route changes: the four app-bar buttons
push exactly the MaterialPageRoutes they did before.
log_meal_sync_mixin.dart background sync + app lifecycle observation
log_meal_nav_mixin.dart the four app-bar destinations
log_meal_actions.dart the app-bar IconButton list
log_meal_progress.dart buildTodayProgress + parseMacroField (both pure)
The sync and nav halves are mixins rather than helper classes because the
behaviour is inseparable from the State it drives -- it observes the widget
lifecycle, calls setState, and reads `mounted`. The screen's fields stay
declared on the State; `syncHttpClient` is an abstract getter because `widget`
is typed `T` inside a mixin, so `widget.httpClient` does not resolve there.
Names crossing the new file boundaries became public with doc comments, as
`public_member_api_docs` requires. The macro-filling block moved onto
`MacroControllers.fillFrom`, next to the existing `clear()`, which is where it
belonged anyway.
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
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
Two Dart files whose contents were already cleanly separable.
wrapper_server.dart (254 -> 228): contentTypeFor is a pure function of the
path with no server state, so it moves to content_types.dart as a plain
library. WrapperServer.contentTypeFor stays as a static forwarder because the
test reaches for it by that name.
sync_merge.dart (475 -> 190): three near-independent blocks -- daylog, banks,
budget -- split along the seams the tests already use. The banks and budget
adapters move to siblings and are re-exported, so sync_service.dart and both
test files import exactly what they did before and needed no edits.
Flutter 555 tests unchanged; analyze clean; gate 52 -> 49.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
The inline `_DeviceCodeDialog` in `github_mirror_screen.dart` (88 lines)
was byte-for-byte identical to wake-alarm's, and
`services/github_device_auth.dart` was a fourth copy of the auth client.
Both now come from `github_device_auth` in the kuhyx/utils monorepo.
This app's copy was the one that mattered most to get right: it took the
device-code and token URLs as parameters because GitHub's device-flow
endpoints send no CORS headers, so the desktop **web** build has to route
both through the local wrapper (`github_client_factory_web.dart` passes
them). Extracting todo's simpler hardcoded version would have broken that
build outright. The package carries the configurable endpoints instead,
defaulting to GitHub's own URLs so the other three consumers are
unaffected -- and `flutter build web` here is verified green, which is the
check that would have caught it.
The dialog also gains a real fix: `on Object` rather than `on Exception`,
so an `ArgumentError` shows an error instead of leaving the dialog
spinning on "Waiting for authorization…" forever.
The unused `token_vault` import in the deleted client confirmed it had no
app-specific behaviour left worth keeping. 548 tests pass, analyze clean,
web build green, and the release APK is installed on the phone with meal
history intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
The chip was the only control that logged a meal against no slot at all
(`slot: null`). Removing it leaves the row with just the four hour chips,
so every logged meal now satisfies a real slot.
Storage keeps its null-slot support on purpose. Entries already on disk
and arriving over sync carry no `slot` key, and the `slot != null` filters
in log_storage_service.loggedSlotsToday / _state.logged_slots_today are
what stop them from retroactively satisfying meal slots -- so both are
documented as load-bearing rather than deleted as dead code.
Also corrects the MCP tool's docstring, which claimed its slot argument
"may be None early in the day"; it falls back to slot_for_log, which
clamps to a real hour and never returns None.
Adds the widget test the chip row never had -- being untested is why the
chip read as unused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPheLM86xUN9TGf34Ygtfz
Navigator.push is annotated @awaitNotRequired, so these now trip
unnecessary_unawaited and failed the CI-mirror's flutter analyze -- which
blocked every push to this repo regardless of what the commit changed.
flutter analyze: no issues. flutter test: 553 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
Splits the settings screen: Nutrition (daily kcal goal) and Notifications
(battery exemption) stay in place, Firebase+GitHub content is replaced by
a Sync settings link using the shared sync_settings_ui package. GitHub
stays app-local as its own GitHubMirrorScreen (device-flow connect,
_ClientIdSetupDialog, the wrapperManagedToken desktop-wrapper path) since
connecting it also triggers a real cross-device sync via runSync(), not
just a settings save.
BackupSlot left null: diet-guard has no local export/import feature.
553/553 tests passing, 100% coverage on settings_screen.dart, flutter
analyze clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1