`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 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/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
diet_guard pushed as "pc" (Python), "phone" (Android) and "desktop" (web).
A role constant collides the moment a second machine takes the same role,
and after a reinstall the new install inherits the previous one's CRDT
identity -- the same reasoning home_inventory already records for choosing a
uuid. Both halves now mint a uuid once and persist it: the PC in
~/.local/share/diet_guard/.device_id, the app under SharedPreferences
'crdt.nodeId' (the key todo and home_inventory already use).
The old constant survives as the legacy id and is passed to sync_log /
syncLog, so devices/<legacy>/ is skipped as this device's own. Without that
every tick would re-download and re-merge this device's own pre-migration
log as though a peer had written it -- idempotent under LWW, but hundreds of
KB, 96 times a day. It drops to None once that path is reclaimed; a test
covers both states, including a negative control proving the skip is the
legacy id's doing.
All four documents move together (food_log, food_bank, food_bank_manual,
budget), and the hist:<date> fields riding on the budget record are
untouched -- merge_record unions field names, so they relay through
unchanged.
The resolver lives in its own module (_device.py / sync_device_id.dart)
because _sync_merge needs it for HLC stamping too and importing _sync there
would be a cycle. Dart resolves it asynchronously at startup, before
LogStorageService.init(); currentSyncDeviceId falls back to the compile-time
role constant until then, so a too-early Hlc stamp gets a valid id rather
than crashing.
conftest redirects the new id file in the same change that introduces it --
without that a test mints a uuid into the real state dir and this machine's
live sync identity is decided by whichever test ran first. Verified by
checking ~/.local/share/diet_guard/ before and after: untouched.
Verified live, not just against mocks: the sync timer ran mid-change and
migrated this machine to f8f46da6. All 358 food_log records and all 100
food_bank records are present under the new path (0 missing vs devices/pc/),
and the new log's HLCs carry only its own id -- confirming it did not
re-merge the old path as a peer.
Python: 687 tests, 100% branch coverage. App: 518 tests, analyzer clean.
Pins bumped to crdt-sync-v0.6.0 / crdt_sync_dart-v0.7.0 (pyproject,
requirements.txt, .pre-commit-config.yaml, pubspec), and the new library is
installed into /usr/bin/python's user site-packages so the production timer
does not ImportError on its next tick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
Four asks, plus the fixes two review passes turned up.
Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
was the sole trigger for. Replaced with a post-log progress card showing
kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
`components` field stays: nothing writes it now, but historical composite
entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
wrapper's /blobs/ route, image_picker, and the CAMERA permission.
Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
across Python and Dart. Deliberately separate from elapsed_slots, which
would otherwise make every slot fall due at 23:00.
Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
the budget in force then. Verified live: setting 2000 left a 36-day
adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
merges per-field over the union of field names and both devices push the
merged log, so a device that predates the feature relays them untouched --
no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
empty history after merging with a pre-feature peer, and a presence check
would then never seed again, silently reclassifying every past day.
One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
was collateral damage of whole-map LWW -- any phone budget edit deleted it,
and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
eating them) was the last device-local state. The derived half merges with
count as the clock, so LWW means max-count-wins; the log stays
authoritative for membership, because a CRDT union never shrinks and a
peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
a cast failure there is an Error, not an Exception, so one malformed
remote record would have broken phone sync permanently.
_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main.dart now builds MaterialApp's theme from buildAppTheme() instead of
ColorScheme.fromSeed(Colors.teal) -- theme.dart existed but was never
actually used until this commit. photo_viewer_screen.dart,
settings_screen.dart, and day_status_calendar.dart's raw Colors.*
literals (Colors.black, Colors.red, Colors.white38, Colors.grey.shade800,
...) now read from Theme.of(context) instead.
Also rewrites CLAUDE.md's Visual Design & Theming section, which
previously contradicted the unified-design-system skill (noise texture,
multi-layered shadows, ColorScheme.fromSeed -- all things that skill
explicitly forbids).
This was the other prerequisite the on-fill/warm-palette fixes actually
needed: theme.dart existing but unwired meant the app was still running
Flutter's default seeded theme.
Completes the move: `linux/` is gone, replaced by the web build served by the
wrapper in a Chrome `--app` window. The icons and the .desktop installer move
out of `linux/` first -- they are not Flutter scaffolding and are still
needed; the entry now points at the packaged launcher and matches the
`--class=diet_guard_app` the wrapper passes, so the taskbar shows the app's
icon rather than a browser's.
install_arch.sh carries over the three traps each of which produced an
install that looked fine until launched: `dart build cli` rather than
`dart compile exe` (which refuses any package whose dependency graph has
native build hooks), `options=('!strip')` (makepkg would otherwise discard
the AOT snapshot, leaving a binary that runs as the bare Dart VM printing a
usage message), and no browser in `depends` (this machine's policy uninstalls
`chromium` seconds later, failing dependency resolution).
Also adds the frame-cadence instrument the performance claim is measured
with, armed by `--dart-define=DIET_GUARD_FRAME_STATS=1` and read with
`String.fromEnvironment` -- `bool.fromEnvironment` only accepts the literal
`true`, so `=1` would evaluate to false and a whole run would silently
produce no data. It reports frame-to-frame cadence rather than build/raster
durations, since work that fits the budget says nothing about the frame rate
actually achieved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flutter's Linux embedder manages ~20fps at 3840x2160 -- measured on a stock
`flutter create` counter app, so it is the toolkit rather than this code --
where the same Dart rendered by Chrome sustains ~144fps (evidence in
~/todo/docs/desktop-performance-findings.md). The desktop target therefore
becomes a Flutter *web* build; Android is untouched.
`dart:io` does not fail a web *compile* -- it resolves to a stub that throws
at runtime -- so every platform edge moves behind a conditional export rather
than being discovered later as a blank white window:
- storage: `document_store*.dart`, files on Android and IndexedDB in the
browser (mirrored to the desktop wrapper, which is what makes a wiped
Chrome profile recoverable). Document names stay the exact pre-web
filenames so an installed phone keeps reading its existing log.
- photos: `blob_store*.dart` plus `attached_image*.dart`, since `Image.file`
cannot resolve an IndexedDB key. The picker itself has web support; what
changed is the storage and display model. Camera *capture* is genuinely
lost on desktop -- and photos never crossed devices anyway, `imagePath`
being stripped before push.
- scheduling: `background_tasks*.dart`. A browser has no WorkManager
equivalent -- a closed tab runs nothing -- so the desktop runs an in-page
5-minute check while its window is open. That loses nothing on the PC,
where `diet-guard-gate.timer` already locks the screen on an unlogged
slot, which is strictly stronger than a notification.
- notifications: `notification_backend*.dart`, the browser Notifications API
in place of flutter_local_notifications.
- the GitHub token: `token_vault*.dart`. On web it is *not* held by the
browser at all -- flutter_secure_storage's web backend is WebCrypto-wrapped
localStorage with the wrap key beside the ciphertext, which is obfuscation,
not encryption at rest -- so the desktop wrapper owns it and proxies the
API (`github_client_factory*.dart`).
`Platform.isAndroid` itself throws on web, so every platform branch now tests
`kIsWeb` first; the battery-optimization row (permission_handler, no web
implementation) is hidden off Android.
The desktop pushes under device id `desktop`, not `phone`: sharing an id
would have made the two devices overwrite each other's pushed log on every
tick. The Python side enumerates device directories generically, so it needs
no change.
Tests move onto the same seams -- the file-backed stores keep the atomicity
and unreadable-file guarantees where they belong.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Work-in-progress feature set accumulated ahead of the log-meal compact
layout change: a food bank browser, a shared entry-edit screen, an
app-wide settings service, and a substantially reworked history screen
with filtering/sorting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UorgLvWJ4huH55tmXoUAZ
M3 – GitHub OAuth device flow: replace PAT-paste with a guided "Connect
GitHub" button that runs the device-code flow; tapping with no client id
now opens a setup dialog (instructions + inline paste field) rather than
a buried inline hint. Bakes in the app's own OAuth App client id so fresh
installs work with zero manual config. Auto-syncs immediately after
connect. Verified end-to-end on the real phone: OAuth flow → token saved
→ PC's 48-entry log merged in (confirmed via food-bank vs manual source
labels in History).
M4 – Background meal-slot notifications: WorkManager periodic task (15 min
floor) checks for overdue slots and posts/cancels notifications via
flutter_local_notifications. New permissions: POST_NOTIFICATIONS,
WAKE_LOCK, RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
INTERNET (was missing — latent sync bug). "Disable battery optimization"
button in Settings. Verified on real phone: WorkManager registered, forced
run posted a real notification ("Meal not logged / You haven't logged your
16:00 meal yet."), isolated to background path (only caller is the
WorkManager dispatcher, not any foreground lifecycle hook).
AGP9 release crash fix: AGP 9 defaults isMinifyEnabled/isShrinkResources
to true for release even with no proguard config; R8 stripped
WorkDatabase_Impl's reflection-only constructor, crashing every launch
with NoSuchMethodException. Explicitly disabled both flags in
build.gradle.kts. Verified via dexdump (constructor present) and on-device
launch (no crash). Proper R8 keep rules are the long-term fix; tracked.
177 tests, flutter analyze clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWPUBzE24Ls9i9GMRwXnnn
Milestone 1 of the diet-app-as-wise-balloon plan: a phone-native way to
log meals away from the PC, sharing the exact on-disk JSON shape
diet_guard already uses (same field names, no translation layer).
- lib/models/: 1:1 Dart mirrors of the Python dataclasses (Nutrition,
FoodEntry, MealItem, FoodBankRecord, Slot), including the per-100g/
amount-eaten portion scaling that matches _resolve.resolve_nutrition's
semantics exactly.
- lib/services/log_storage_service.dart: plain-JSON persistence to
food_log.json's exact shape (no sqflite -- the canonical format
already is this JSON).
- lib/services/foodbank_service.dart: ports _foodbank.py's upsert/fuzzy
search logic for autocomplete.
- lib/screens/: log_meal_screen.dart (single-item logging) and
meal_builder_screen.dart (composite multi-item meals, logging full
per-component macros via the new components field).
Verified end-to-end on a physical device (BL9000): built, installed,
logged a real meal through the UI. 77 Flutter tests passing, `flutter
analyze` clean against very_good_analysis.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF