Commit Graph

16 Commits

Author SHA1 Message Date
cb8028bd22 app settings: exact-time clock button replaces the "Custom…" item
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
2026-10-10 20:10:55 +02:00
a9aa379a13 app tests: off-hour schedule round trips + real settings wiring
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
2026-10-10 20:00:45 +02:00
f83938bfe3 app: minute-of-day slots (phase 2 of 3: every app consumer)
- 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
2026-10-10 19:55:26 +02:00
da8822006f Let the phone set the eating window and meal count
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
2026-08-16 14:56:12 +02:00
c0ed6154ac Split the remaining seven Dart test files
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
2026-08-15 20:53:55 +02:00
45471546f7 Migrate Sync settings to the shared sync_settings_ui package
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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
2026-08-14 11:47:43 +02:00
546041584f Add Google sign-in support for the Firebase sync account
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
One-tap sign-in for a fresh install, matching the pattern already shipped
in todo and crdt_sync_dart: openFirebaseWithGoogle() (google_platform.dart
abstracts the plugin's platform-channel dependence), isFirebaseConfigured()
that reads the stored session rather than trusting the account marker
alone (a marker with no live session is what made a synced phone read as
"not connected" for a full session), and storedAccount() for read-back
without the desktop-wrapper fallback that throws on Android.

Also generalizes crdt_sync_dart's ref pin to v0.10.0 and vendors the
override-guard script for the pre-commit hook already wired to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-12 23:36:10 +02:00
458f10515b Make the phone publish on every tick, and fail closed on Firebase errors
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Meals logged on the phone stopped reaching the PC, so the gate kept locking
for slots that were already logged and the only way through was to spam
"nothing". The PC's fetch was never broken: it was faithfully pulling a
Firebase that did not contain the phone's meals.

The phone had no working publish backstop on either transport:

- `checkAndNotify` gated its sync on `due.isNotEmpty`. Logging every meal
  promptly means nothing is ever due, so the ~15-min periodic task never
  published. It was a notification job that happened to sync, not a sync
  timer. It now syncs on every tick it is asked to, then computes due slots
  from the merged result, so a meal pulled from another device suppresses a
  nag instead of triggering one.
- `backgroundSyncPush` never called `initSyncDeviceId()`. A fresh isolate has
  its own static state, so `currentSyncDeviceId` fell back to the compile-time
  'phone' constant and published to `devices/phone/` rather than the persisted
  uuid -- which then makes the next foreground push see `unchanged` and skip
  writing the uuid directory entirely.

Separately, on the "Fetch from sync" path itself: `FirebaseSyncError` and
`FirebaseAuthError` are siblings of `GitHubSyncError` under `RemoteSyncError`,
not subclasses, so every per-request failure of the *primary* backend escaped
`pull_shared_log`'s catch tuple and raised a traceback out of a helper
documented to fail closed with a reason string. `ConfigError` needed the same
treatment by a different route: it subclasses `Exception` directly, so it is
outside `RemoteSyncError` too, and is now translated at `_client_for_run`.

The PAT is no longer mandatory either. Firebase is primary, but `_read_token`
ran before the client was ever built, so a Firebase-only machine could not
sync at all -- the commits that fixed this touched only the Dart side.

A stalled sync was invisible: the config gates returned silently and
`openFirebase` swallowed every error. `dart:developer`'s `log()` does not
reach logcat on a release build (verified on device), so logging alone would
not have surfaced it. `SyncHealth` records each tick's outcome and the log
screen shows a banner when publishing has actually stopped.

Also make the real-Tk probes bypass the window manager. A tiling WM retiles a
managed toplevel instead of honouring the requested geometry, so `winfo_height`
reported 0/1px and the layout and focus-ring assertions failed on noise rather
than on any regression -- 8 failures on the dev machine that only passed under
Xvfb. `overrideredirect` makes the measurement deterministic with or without a
WM; the probes still detect a genuine overflow at 1366x300.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RT8X32S9yDLW1Xf1PDq8z7
2026-08-10 14:35:37 +02:00
4d803203ab Make Firebase the visible sync path, and GitHub the mirror it now is
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Four things about the sync settings read wrong on the phone, all of them
left over from when GitHub was the only backend:

- "Connect GitHub" sat on the primary path as a peer of "Connect Firebase",
  implying a choice. It is not one: GitHub is the cutover mirror. Its whole
  configuration -- device-flow OAuth, owner, repo, client id, PAT fallback --
  now lives under "Advanced (GitHub mirror)".
- "Test connection" builds a GitHubClient and calls canAccessRepo(). It has
  never said anything about Firebase, so it is now "Test GitHub connection",
  and its result messages name GitHub too.
- Once connected, the account still rendered as an editable email box beside
  an empty password box, which reads as "you still have to enter this" -- a
  connected device looked unconfigured. It now shows the account as text with
  a Disconnect button, and the form appears only when disconnected.

Also pins crdt-sync v0.5.1 (from v0.3.0), the shared-config release the
Firebase cutover needs; verified importable under /usr/bin/python3, which is
what diet-guard-gate.service actually runs.

Adds coverage for both Firebase states, which had none: the disconnected form
and the connected read-only display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
2026-08-05 08:55:12 +02:00
f7f06aee53 Remove unused features; fix reminder false alarms; share all state
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
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>
2026-07-26 16:39:52 +02:00
802ee0fe6d app: run the companion app's desktop target as a web build
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>
2026-07-20 16:31:32 +02:00
10364dc1ff app: one-tap repeat-last-meal FAB with optional temptation-bundling reward
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Applies Atomic Habits' two-minute rule: a FAB on the home screen repeats
the most recent logged meal in a single tap, mirroring _onLogMeal's
existing post-write sync sequence. An optional device-local reward
(label + URL, configured in Settings) surfaces after a repeat and opens
externally via url_launcher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 10:09:36 +02:00
59d1c29dd2 Migrate sync to shared crdt_sync library (Python + Dart)
Replace the bespoke GitHub-Gist sync with the shared crdt-sync /
crdt_sync_dart libraries so diet-guard uses the same CRDT transport as
the other apps.

- Python: delete _sync_github.py (+ its test); rewrite _sync.py,
  _cli_sync.py, _sync_merge.py against crdt-sync. Add the crdt-sync git
  dependency (pyproject + pylint deps) and S107 to the test ignore list.
- Dart: rewrite app sync_service.dart / sync_merge.dart against
  crdt_sync_dart (new git dep + crypto); update log_meal and settings
  screens and their tests.

Verification: pytest 347 passed at 100% coverage; flutter analyze clean;
flutter test 306 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
2026-07-10 23:49:43 +02:00
a121da211d Point sync at the consolidated utils/syncs monorepos
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
gatelock and diet-guard-sync were folded into shared monorepos:
gatelock -> github.com/kuhyx/utils (subdirectory), diet-guard-sync ->
github.com/kuhyx/syncs (subdirectory). Update the gatelock pip URL,
SYNC_REPO_NAME, the devices-dir path prefix (PC and phone), and add a
persisted-value migration so an already-configured phone picks up
'syncs' on next load.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 14:12:16 +02:00
adbfb20e9a Add OAuth device flow, background notifications, and fix AGP9 release crash (Milestones 3–4)
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
2026-06-25 17:29:23 +02:00
a82047502f Add Flutter half of cross-device sync (Milestone 3)
Ports github_client.dart and sync_settings.dart from ~/todo (PAT-paste
instead of OAuth device flow), and writes a new (non-CRDT) sync_merge.dart
and sync_service.dart matching diet_guard's Python _sync_merge.py/_sync.py
algorithm exactly. Adds a settings screen for the PAT plus manual "Sync
now", and wires lifecycle-triggered auto-sync (launch + resumed/paused)
into the main logging screen, silent on failure per plan decision 4.

Also adds Linux desktop platform scaffolding so this and future UI changes
can be visually verified without a connected phone.

Verified end-to-end against the real kuhyx/diet-guard-sync GitHub API on a
Linux desktop build: Test connection and Sync now both round-trip to GitHub
and surface real auth errors correctly via SnackBar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RH2BHCKbDTiYJUMG3rb9nq
2026-06-22 22:42:27 +02:00