Commit Graph

71 Commits

Author SHA1 Message Date
dac31c997e packaging/aur: use sha256sums=('SKIP') in the committed template
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
The template previously carried the real hash of the current tag's tarball.
That tarball contains this very file, so the hash is stale the moment the next
tag is cut -- and a stale 64-hex string looks complete enough that someone
copying the template to the AUR checkout could skip updpkgsums and then debug a
checksum mismatch instead of the actual step they missed. 'SKIP' is
unmistakably a placeholder. The AUR checkouts under ~/aur keep the real hashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
2026-08-09 22:58:15 +02:00
291c60417b Add the real AUR PKGBUILD as packaging/aur
app/install_arch.sh generates a PKGBUILD into a mktemp dir with no source=() and
a package() that copies from hardcoded absolute paths under /home/kuhy. That
only ever works on one machine, so it cannot be published. This PKGBUILD builds
from the GitHub release tarball (v1.0.1) instead, and fixes what AUR review
would reject: license=('MIT') with the text actually installed to
/usr/share/licenses, real depends (glibc/bash/hicolor-icon-theme, found by
namcap), and the desktop entry plus icons installed into the package rather than
into ~/.local/share after the fact.

The app lives in app/ while LICENSE is at the repo root, so build()/package()
cd into the subdir and install the license from an absolute $srcdir path.
v1.0.0 was already taken by an older commit predating the LICENSE, hence v1.0.1.
The README documents the tagging hazard: this clone carries grafted upstream
Flutter SDK tags that must never be pushed with `git push --tags`.

install_arch.sh keeps its working-tree dev loop and is deliberately not wired to
this file. Verified: makepkg -Cf from a clean dir with only packaged Flutter on
PATH, namcap clean, pacman -U, and `diet-guard-app` serving on port 8732.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
2026-08-09 22:56:01 +02:00
f0a8337b35 Add MIT LICENSE
Required for AUR packaging: a package declaring license=('MIT') must install
the license text to /usr/share/licenses/, and review rejects packages that
cannot. Matches the identical MIT text already used by the todo app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
2026-08-09 22:49:29 +02:00
688bd7b1de Finish the three sync bugs: gate, SIGKILL flag, and silent catches
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
Auditing the whole fleet for the bugs found on the desktop turned up three
that were still live here.

1. `--virtual-time-budget=45000` was still in the provisioning args. The
   previous commit added the comment explaining why it must not be there but
   left the flag itself, so headless Chrome kept SIGKILLing itself (exit -9)
   and could still truncate a sync. Actually removed now; a provisioning run
   exits 0.

2. `backgroundSyncPush` gated on `settings.isConfigured` -- "has a GitHub
   token" -- so the WorkManager background push was skipped entirely on a
   device connected only to Firebase. Same defect as _autoSync's, one layer
   down. Either backend now counts.

3. Three swallowed exceptions, all in the sync path: the in-isolate config
   read, the background push itself, and `loadAccount()`. The last one is the
   worst -- it hid *why* provisioning failed, which is indistinguishable from
   "no account set" until it says so, and cost real debugging time this
   session. All log at warning/error with the stack trace and stay non-fatal,
   per the repo's no-silent-failures rule.

Verified after the change, not just compiled: provisioning exits 0 and the
desktop (c891b813) still holds 359 food_log records, identical to the PC
daemon's, 0 missing.

522 tests, analyzer and pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 22:28:18 +02:00
d388f90ed4 Stop SIGKILLing the provisioning browser, and say why auto-sync failed
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
Two fixes found while verifying the desktop actually reaches Firebase rather
than trusting an exit code.

`--virtual-time-budget` makes headless Chrome SIGKILL itself when the budget
expires: the run reported exit -9 and could cut a sync short mid-flight. It
now gets a real wall-clock window and is closed cleanly, so the exit code
means what it says.

`_autoSync` caught `on Exception` and ignored it entirely — the exact
silent-failure shape screen-locker's CLAUDE.md bans ("an invisible log is a
silent failure with extra steps"). It now catches broadly and logs at error
level with the stack trace, so the next failure is diagnosable instead of
looking identical to "nothing to sync". `--enable-logging=stderr` on the
provisioning run surfaces that in the terminal that started it.

Verified end to end, which is what settled it: the desktop appears in the
RTDB as c891b813 with all four documents and a revs entry, and its food_log
holds 359 records — identical to the PC daemon's, 0 missing either way. An
earlier check read as "not pushing" because it ran before the push landed.

522 tests, analyzer and pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 22:15:34 +02:00
c093c22ae0 Auto-sync on a Firebase-only device, and provision with no window
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
Two bugs, found because headless provisioning silently did nothing here while
it worked in todo.

`_autoSync` returned early unless `settings.isConfigured`, which means "has a
GitHub token". A desktop connected only to Firebase therefore never synced at
all -- not on this run, not ever -- and since the provisioning fallback is
reached through openFirebase(), it could never provision either. This is the
same defect workout_app's push() already documents ("Gating on the GitHub
token alone silently dropped every push from a device connected only to
Firebase -- and once the mirror is retired that would be every device").
Either backend now counts.

--provision-sync-account then does the setup with no window: the account
lives in origin-keyed localStorage so the page must run, but --headless=new
runs it without ever mapping a window. Startup auto-sync calls loadAccount(),
the wrapper fallback provisions, the process exits.

Verified after the fix, having watched it fail twice before it:
FlutterSecureStorage.firebase.account now holds a real AES payload in the
desktop profile's localStorage. The time budget is 45s rather than todo's
15s -- this log is an order of magnitude larger.

Usage:  CRDT_SYNC_SERVE_ACCOUNT=1 diet-guard-app --provision-sync-account

522 tests, analyzer and pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 22:00:53 +02:00
19991f3600 Make the sync-account route testable, and cover every branch
Same change todo just took: the route read its own configuration from
Platform.environment and $HOME, so only the "disabled" branch was reachable
from a test. Both are now constructor parameters defaulting to the
environment -- production behaviour unchanged -- and the four outcomes are
covered: 404 disabled, 200 with the account, 404 when ~/.config/crdt-sync/ is
incomplete, 404 when firebase.json has no usable email.

522 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 21:49:28 +02:00
55fade1e15 Let the desktop provision its sync account from the wrapper
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
Same change todo just took, for the same reason: app/'s desktop target is a
Flutter web build in a Chrome --app window, so FlutterSecureStorage resolves
to the web backend (AES-encrypted localStorage, keyed inside the browser
profile). Nothing outside the page can write that store — `secret-tool`
targets libsecret, which only a Linux embedder build would read, and a Dart
CLI cannot even import flutter_secure_storage, since it is a Flutter plugin
that fails to resolve on the VM. Both verified rather than assumed.

The wrapper is the one process that can read the filesystem and serve the
page, which is exactly why it already holds the GitHub token instead of the
browser (see github_proxy.dart). So the sync account now arrives the same
way: loadAccount() falls back to GET /sync-account when the store is empty
and persists the result, making the route a one-shot provisioning step
rather than a standing dependency.

It reads ~/.config/crdt-sync/{firebase.json,password} — the same pair
`diet_guard sync` already uses — so the machine keeps one source of truth.

Off unless CRDT_SYNC_SERVE_ACCOUNT is set, and 404 (not 403) when disabled so
a probe cannot tell the route exists. On Android there is no wrapper: the
request simply fails and loadAccount() returns null exactly as before.

clearAccount() writes an opt-out marker the fallback checks, so Disconnect
cannot be silently undone by the next launch re-adopting the account.

518 tests, analyzer clean, pre-commit clean. Pinned to crdt_sync_dart-v0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 21:41:08 +02:00
3ff08f93f6 Derive versionCode from the commit count, not github.run_number
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
run_number counts workflow RUNS, not code. Two failure modes, one of which
already bit: it resets to 1 if this file is renamed (the existing NOTE warned
about that), and it can fall BEHIND a build produced outside this workflow.
On 2026-08-09 diet-guard's phone held a manually sideloaded vc 8 while CI's
next build was vc 6, so `adb install -r` refused every CI build with
INSTALL_FAILED_VERSION_DOWNGRADE. The only non-destructive escape was
re-running the workflow until the counter passed 8 -- forcing a downgrade
needs an uninstall, which wipes app data.

`git rev-list --count HEAD` only ever increases, tracks the code rather than
the CI file, and is already far ahead of every installed build (diet-guard
62 vs 9, screen-locker 118 vs 5, wake-alarm 41 vs 5, todo 51).

Needs fetch-depth: 0 -- checkout@v4 is shallow by default and would count 1.
Verified the count is identical from the repo root and from app/, so the
job's `working-directory: app` default does not affect it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 18:15:34 +02:00
e23f7ac3b4 Push under a persisted per-install uuid, not a fixed role id
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
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
2026-08-09 17:18:42 +02:00
1328bece05 Use the platform sync device id for HLC stamps, not a hardcoded 'phone'
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
sync_merge.dart declared its own `const _syncDeviceId = 'phone'`, which was
unconditional -- so the desktop web build wrote records under
`diet-guard-sync/devices/desktop/` while every HLC string inside them ended
in `-phone`. The path segment came from the conditional-export constant, the
HLC did not.

Import `syncDeviceId` from github_client_factory.dart instead, so both halves
come from the same source: 'phone' on Android, 'desktop' on web, matching the
Python side's 'pc' in _constants.py.

Preventive, not a repair: there is no devices/desktop/ in the live RTDB yet,
so no already-written record has the mismatch. HLC tie-break is
nodeId.compareTo, so desktop stamps now sort below phone's rather than tying;
entryHlc is deterministic from entry.time and the bodies are identical for a
given entry id, so this changes which identical copy wins, never what data
survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 16:41:53 +02:00
fe28c8a395 Step Dart dates by the calendar, not by 24 hours
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
A Duration is an absolute span, so stepping a local DateTime by
Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
the wrong wall-clock day. Under Europe/Warsaw, spring-forward 2026-03-29:

  lastCompleteDay(2026-03-30)  ->  2026-03-28 23:00, the day before
                                   yesterday, not yesterday
  weeksAgo: 1 from 2026-04-01  ->  2026-03-24 23:00, a Tuesday, so the
                                   wrong ISO week entirely
  a 03-27..03-31 period        ->  emits four day keys, silently dropping
                                   2026-03-31

The Python half computes all three correctly, because timedelta on a date
has no clock to drift against. So for about two weeks a year the phone and
the PC would report different averages for the same log -- exactly the kind
of device-local divergence the shared-source-of-truth rule exists to stop.

Passing out-of-range components to DateTime's constructor normalizes on the
calendar (day 0 is the previous month's last day), which is what timedelta
already does. The same fix goes to day_status_service's streak walker: it
had the identical pattern and predates this feature, so a streak that
spanned the clock change could break when it should have held.

None of the existing tests could see any of this -- every fixture date sits
in a DST-free span -- so the regression tests use dates that straddle
2026-03-29 and assert on the calendar day rather than the DateTime, which
keeps them meaningful whatever zone the runner is in. Verified they fail
against the old arithmetic before fixing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
2026-08-09 15:06:41 +02:00
e07284d3e1 Show weekly and monthly average intake on both devices
The calendar already said which individual days went over budget, but not
whether the week as a whole did. A run of small overages reads as four
harmless yellow squares; averaged, it is the thing that actually moves the
needle.

Three rules are load-bearing, and are documented in CLAUDE.md because getting
any of them wrong silently changes what "under budget" means:

- The denominator is logged days, not elapsed days. An unlogged day is not a
  zero-kcal day, and averaging gaps in as zeros would make a badly-tracked
  week look like the best week of the year.
- The yardstick is the mean of the per-day budgets over those same days,
  resolved through BudgetSchedule -- never today's budget, which would
  retroactively reclassify past weeks on every budget edit.
- Today is excluded. A half-logged today drags the mean far enough to flip
  the band, so every period ends at yesterday.

Band boundaries reuse the calendar's own OVER_BUDGET_YELLOW_CEILING by
import rather than by copy, so a period and its days can never disagree
about what "over" means; that constant loses its underscore to say so.

The MCP get_averages tool returns the average and the band but drops the
average budget, keeping the module's existing invariant that the budget
number never leaves this machine. The CLI, the gate and the phone all show
both, since it is hidden from the network, not from the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
2026-08-09 14:59:40 +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
da0b54d886 Sync meals and the food bank through Firebase, mirroring to GitHub
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
The largest consumer moves last on purpose: diet_guard was 71% of all sync
traffic, and both its surfaces (the PC timer and the phone app) share one
prefix, so they move together.

Firebase becomes the primary backend when it is set up, with GitHub kept as a
mirror so a device that has not moved yet still converges. An unconfigured
device keeps syncing exactly as before, which is also the rollback.

Revision tracking is new here. The PC hand-rolled its own pull-merge-push
instead of calling sync_log, so it had none: every tick re-downloaded every
peer's whole food log -- hundreds of KB, 96 times a day -- and re-pushed its
own regardless of change. It now reads one small revision map first, skips any
peer whose hash is unchanged, and suppresses a no-op push. Every sub-sync
(log, budget, food bank, manual bank) takes the client as a parameter, so one
swap covers all four.

The app could not have reached Firebase at all: nothing called saveAccount(),
so loadAccount() always returned null and openFirebase() always returned null.
Settings now has a Firebase section that stores the account in the OS keystore
and signs in immediately, so a typo surfaces on the screen where it can be
fixed rather than as a silently failed background sync hours later. The email
and password never touch the repo -- it is public.

Platform reaches now degrade instead of failing: an unavailable keystore or
application-support directory costs one tick of extra traffic, where before it
would have taken sync down entirely. Caught by `on Object` rather than
`on Exception`, because an uninitialised binding raises an Error.

Verified end to end against the real database, not just in tests: a live PC
sync merged 57 days / 339 entries and put 221 KB of food log plus 97 food-bank
records into Firebase; a second unchanged tick issued zero pushes; and the
Dart client signed in and read back all 339 meals and 97 foods, then completed
a push / publish-revision / suppress-no-op round trip under a throwaway prefix.

diet_guard: 618 tests at 100% branch coverage.
diet_guard_app: 477 tests. Two assertions moved from 4 pushes to 5 -- the
extra one is this device's revision, published after the log so a peer can
never cache "seen rev X" against a log it never received.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-05 08:35:21 +02:00
665b0bf935 Stop two keyboard tests hanging, and make the CI mirror mirror CI
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
Two failures, both pre-existing and both invisible as failures — each
reported only as "did not complete" after stalling the suite.

app/test/keyboard_operability_test.dart awaited writeLog() straight from
the test body. LogStorageService is backed by a FileDocumentStore there,
so that awaits real dart:io, and the fake-async zone never completes it:
the test hangs rather than fails. Seeding now happens inside runAsync
alongside the pumping, which is the only zone where that write can
resolve. The screen's own initState load needs the same real clock, and
pumpAndSettle() cannot be used to wait for it — inside runAsync it waits
on a frame scheduler this screen never reports as idle — so the helper
pumps a bounded number of times instead. 473 app tests now pass in 14s.

scripts/ci_mirror.sh ran bare pytest while python-tests.yml runs it under
xvfb-run. Without a display the gate's real-Tk tests collapse: the focus
ring has nothing to walk, so three TestRealFocusRing tests fail locally
and pass in CI. The mirror was reporting red on a green tree, which is
worse than not mirroring at all. It now uses the workflow's exact
xvfb-run invocation and fails loudly if xvfb-run is missing.

Neither change touches production code: the delete control and its
id-gate were already correct, and the Python suite already passed under a
display (609 passed, 100% coverage).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
2026-08-04 21:50:15 +02:00
5f34ba6139 Build and publish a signed APK on every push to main
diet-guard had no APK build at all; releases were not produced. Adds a
standalone release-apk.yml plus the release signing config the app was
missing.

Signing follows the pattern proven in home_inventory: gradle reads
android/key.properties when present and falls back to the debug key when
absent, so local `flutter run --release` is unaffected. CI writes that
file from repository secrets and shreds it afterwards, and apksigner
fails the build closed if a debug-signed APK ever appears.

Only the signingConfig line changes here: isMinifyEnabled, isShrinkResources
and proguardFiles are left exactly as they were.

This repo has no Flutter analyze/test job, so the release job is not
gated on tests — a broken commit still publishes, with the build failing
as the only backstop. Add `needs:` if a Flutter CI job is added later.

versionCode is github.run_number, which is per-workflow-file — this file
must never be renamed, or the counter resets and updates stop installing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
2026-08-04 20:55:14 +02:00
fdce5a1031 Move the crdt-sync pin to v0.3.0
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
Your in-progress bump, committed as its own change so it is not buried in
anything of mine. All three declaration sites move together -- the manifest,
the lockfile-equivalent and pylint's hook env -- because a pin that lags the
others lints the code against a library it does not actually run with.

Verified in a clean venv that v0.3.0 still provides every symbol this repo
imports: GitHubSyncClient, GitHubSyncError, Log, merge_logs, Hlc and Record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 20:08:53 +02:00
58e6731705 Port the MCP server to mcp 2.x, and say what each tool is allowed to do
mcp 2.0.0 removed mcp.server.fastmcp; MCPServer is the same object under a
new name, so @mcp.tool() and mcp.run() are untouched and every tool keeps its
name, signature and returned shape. Verified against a clean 2.0.0 venv: all
four tools list, and the suite passes at 100% coverage, 609 tests.

Two things the port buys, neither of which existed in 1.x:

* Tool annotations, which matter more here than in the sibling repos because
  this server has a tool that writes. The three readers are marked read-only,
  idempotent and closed-world; log_meal is marked not-read-only but
  explicitly NOT destructive -- it appends and never rewrites or deletes --
  not idempotent, since a second confirmed call logs a second meal, and
  open-world, because it reaches Open Food Facts when macros are not given.
  A client can now tell the difference without reading the docstring.
* Structured output: 2.x derives an output schema from the return types and
  sends structured content alongside the text. No code change -- it falls out
  of the type hints that were already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 20:02:00 +02:00
a737843503 Give the test job a display, so the real-Tk tests run instead of failing
This job has been red since the keyboard tests landed: they drive real Tk --
widget heights and the focus ring come from the font engine, and no mock can
answer what they ask -- and a runner has no X display, so they failed with
"no display name and no $DISPLAY" rather than skipping. Thirteen tests,
including the two that assert the gate fits 1366x768, were reporting nothing
about the code.

xvfb-run gives them the display they need, which is what every local run of
these tests already used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 18:14:14 +02:00
df5916f616 Pin mcp below 2.0, which dropped the module _mcp.py imports
CI has been red since 2026-07-30 for a reason unrelated to any commit here:
mcp 2.0.0 removed mcp.server.fastmcp, which _mcp.py imports at module scope,
and the manifests asked for `mcp>=1.9` with no upper bound. Verified
directly -- 2.0.0 raises ModuleNotFoundError on that import, 1.29.0 provides
it. It failed pylint here rather than only the test job, because the repo
root now reaches pylint's import resolution.

Pinned `<2` in all three places the dependency is declared until the server
is ported to the 2.x API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 17:45:50 +02:00
af81136861 Give pylint the repo root, so a script outside the package lints on its own
pylint infers sys.path from the files it is handed, so a file in scripts/
resolves its package imports only when a package file happens to be in the
same batch -- passing or failing by what else was staged. Sibling repos hit
exactly that in CI today. An init-hook puts the repo root on the path
unconditionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 16:38:20 +02:00
4a5e350baf Fit the meal gate on 1366x768 with a full day logged, and stop the auto-scroll
gatelock 0.4.0 (pinned here) stops the viewport following the app's own
focus_set(): on 2026-08-03 that moved the screen locker's lock screen to
mid-content and back with nobody touching the keyboard, and this gate uses
the same viewport. Scrolling now happens only on the user's own keypress,
wheel or click.

That makes fitting the screen the gate's own responsibility rather than
something a scrollbar covers for, and with a day of meals logged this one did
not: 770px of content in a 739px pane. Trimmed the vertical rhythm (the
title is one step down the scale, and the gaps around the description,
suggestions, macros and footer are one step tighter) for 718px, with room for
the dashboard to keep growing.

Its layout test now requires the gate to *fit* rather than merely be
reachable by scrolling, empty and populated. Dropped the 1024x600 tier: no
machine runs it, this gate cannot reach it without dropping content, and a
target nobody uses is a constraint that only ever costs.

The probe presses Tab before moving focus, because a bare focus_set() no
longer scrolls -- it would have been testing a path no user can take. The
Tk fakes learned pack_forget(), which the viewport calls to take the
scrollbar down whenever the content fits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 16:00:13 +02:00
1702685afd Make the meal gate usable without a pointer on a 1366x768 screen
The Log Meal tab did not fit this laptop, and had not for some time. Measured
under a real 1366x768 X server: it required 866px inside a 739px content pane
with every field EMPTY, and 938px with a day of meals logged.

Because the content frame was place()-centred it was sheared at BOTH edges at
once -- the "Diet Gate" title and the slot header (which meal you are being
asked to log) off the top, the calorie headline and the whole dashboard off the
bottom -- with no scrollbar anywhere in the package and nothing to indicate
content was missing. This is a hard lock: overrideredirect, a global grab, VT
switching disabled. Unreachable content is unrecoverable.

Two independent causes, both needed fixing:

- Every font size was handed to Tk as *points*. Tk encodes the unit in the
  SIGN of the size, and _gatelock_typography carried a comment asserting the
  opposite ("Tk has no separate px/pt distinction"). At ~100 DPI that made
  every string ~30% oversized. Negative sizes now; the docstring records why.
- There was no scroll container. build_layout now uses gatelock's
  ScrollableSurface, which top-anchors, scrolls by keyboard, and scrolls to
  follow focus.

Font sizes alone were not enough: they take the layout to 702px, still over the
571px pane at 1024x600. Both fixes are load-bearing.

The grams/items selector was mouse-only. tk.OptionMenu's Menubutton defaults to
takefocus=0, so ::tk::FocusOK rejects it and it never entered the tab ring --
verified by walking the real ring, which ran Entry -> Text -> Listbox -> Button
-> TNotebook and skipped it entirely. Switching units rescales the whole entry,
so a pointerless user could not correct "250 grams" to "3 items" inside a lock
they cannot leave without logging a meal. It is now two Radiobuttons, which are
in the ring by default and take both Space and Return -- and which post nothing,
unlike a menu, whose grab-stealing is why screen-locker bans OptionMenu on lock
surfaces outright.

Also: the focus ring was invisible (Tk's default is 1px BLACK on #211D1B) and
_build_desc actively inverted it by setting highlightbackground, the UNfocused
ring, so the box outlined while idle and went black the moment it took focus.
Enter now activates buttons (X11 Tk binds only <space>). Text widgets no longer
trap Tab. notebook.enable_traversal() is called, so Ctrl+Tab and Ctrl+PageDown
work. wraplength drops 900 -> 640, the line-length cap, which also stops a
label forcing a 900px-wide layout onto a 1024px screen.

The Flutter app had two controls with no keyboard route at all. Its desktop
target is the web build in a Chrome --app window, so a touch gesture is not a
fallback there, it is the only interaction:

- Deleting a history entry was bound solely to onLongPress. Long-press has no
  keyboard equivalent whatsoever. It now has a focusable IconButton; the
  long-press stays as a redundant pointer shortcut.
- Calendar day cells were a bare GestureDetector, so navigating to
  date-filtered history was pointer-only and invisible to a screen reader.
  Now Semantics(button: true) over an InkWell.

Plus: the filter sheet built a TextEditingController inside build() with the
caret forced to end-of-text, so every keystroke discarded it and silently undid
mid-string Home/End/arrow editing. And nothing capped width, so on the 1366px
desktop window prose ran ~180 characters per line (twice the readable limit)
and a four-digit kcal field spanned ~1334px. AppWidth adds those caps.

Tests: the render check is a failing test, not a script -- ruff runs with
--unsafe-fixes, whose T201 fix DELETES print statements, which would silently
turn a diagnostic into a no-op. test_gate_layout_fits.py was verified red on
the pre-fix code ("content 866 in viewport 768 -> NO SCROLL VIEWPORT ->
UNREACHABLE CONTENT") and green after. The Flutter suite gains its first
keyboard-reachability assertions; it previously had ~109 tester.tap calls and
zero checks that anything was focusable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:21:27 +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
94081233f2 ci_mirror: run flutter with the git hook's environment stripped
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
The pre-push gate failed on `flutter analyze` with "The current Flutter SDK
version is 0.0.0-unknown", while the same command passed when run by hand.

`git push` exports GIT_DIR to its hooks, and the flutter tool shells out to
git to identify its own SDK. With GIT_DIR set it reads THIS repository
instead of /opt/flutter -- `flutter --version` inside the hook reports

    Flutter 3.47.0-0.1.pre • channel main • https://github.com/kuhyx/diet-guard
    Framework • revision 84bd5503 (our own HEAD)

so pub cannot resolve any SDK constraint and every dependency is "forbidden".
Worse, flutter writes that answer into its own version cache, so the next
caller -- in any repo -- inherits it until something repairs it.

Wrapping the two flutter calls in `env -u GIT_DIR ...` fixes it at the point
of use. Not `--no-verify`: the gate was right that something was wrong, it
just was not the code being pushed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:26:54 +02:00
c6663da06a Pin gatelock 0.2.1 for the recovery-loop fail-open fix
0.2.0's recovery loop scheduled the next tick after running it, so any
exception out of a tick permanently ended all re-assertion of coverage, grab
and VT for the rest of the lock -- silently, since Tk logs and swallows
exceptions from `after` callbacks. 0.2.1 moves the reschedule into a
`finally`.

Also in 0.2.1: `holds_grab` no longer reads a grab held by one of our own
transient children (a posted menu) as "the grab was lost" and takes it back.
That one was a real, user-visible bug on screen-locker's gate windows; the
same class of widget on a diet_guard gate would have behaved the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:21:19 +02:00
84bd550331 Migrate diet_guard to gatelock v0.2.0 per-output locking
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
The gate is laid out once per live output instead of once on the primary,
and claims the screen through an Arbiter at RANK_DIET_GUARD so the wake alarm
and the workout locker can both outrank it.

Three kinds of state needed three different answers. The display fields
already fanned out for free -- GateVars holds root-mastered StringVars bound
to labels. The typed-in fields did not: amount, per, meal name and the four
macro cells were bare tk.Entry widgets, so N copies would have been N
independent forms. They are variable-backed now, which is what makes logging
work from whichever screen the user is actually looking at. The description
box and the suggestion list cannot be shared at all -- tk.Text and tk.Listbox
have no variable -- so those genuinely diverge and reads answer from the copy
the user touched.

The History tab is per-surface too: its grid, budget field and edit button
are driven across every monitor, with the budget likewise variable-backed so
the value cannot differ between screens mid-edit.

Laying the gate out on the primary monitor only was considered and rejected:
not having to hunt for the screen the form landed on is the entire point of
per-output locking.

Tests: the suite hung rather than failed on first run -- diet_guard patches
its own `tk`, but LockWindow builds Toplevels through gatelock's, and real
tkinter over a MagicMock root recurses without bound. `_hermetic_gatelock`
closes that and redirects the runtime dir so no test can stand a production
locker down. Every other test runs single-output, where fan-out is
unobservable, so test_gatelock_fanout runs on two; each assertion was checked
by cropping the fan-out loops to the first surface and confirming it goes red.

_gatelock_ui and _gatelock_calendar were each split in two to stay under the
500-line limit, and GateVars' typed-in fields moved into a nested
GateEntryVars rather than raising pylint's attribute cap.

470 tests, 100% branch coverage, pre-commit clean, no lint suppressions.
2026-07-25 16:56:13 +02:00
a106970b4f theme.dart: define secondary/tertiary ColorScheme roles
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
Found via testing todo's app, which shares this theme.dart template and
has a SegmentedButton: neither ColorScheme.dark() constructor call
defined secondary/tertiary (or their container/on- variants), so any M3
widget reaching for secondaryContainer silently falls back to Flutter's
stock Material teal instead of erroring. Confirmed this app has the same
live bug — ChoiceChip (slot selector) uses secondaryContainer implicitly,
and FilterChip's "Any"/"With photo"/"Without photo" chips in the History
filter sheet were rendering in stock teal in production right now.

The shared palette has one accent, not a separate secondary hue, so these
mirror primary; secondaryContainer/tertiaryContainer use line-dark as a
distinguishable-but-subtle selected-state fill.

Verified: flutter analyze clean, 436/436 tests, re-verified identical in
an isolated worktree, and confirmed live: rebuilt debug + release APKs,
installed via adb install -r (no data wipe), screenshotted the real
device before/after (the History filter chips went from teal to warm
gold/tan), then rebuilt and redeployed the desktop package
(install_arch.sh -> pacman -U) and confirmed the running desktop window
renders identically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 19:46:07 +02:00
c935a202cc Make AppStatusColors lookup null-safe; fix tests that never had it
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
Theme.of(context).extension<AppStatusColors>()! crashed in any context
that doesn't build its theme from buildAppTheme() -- which is most widget
tests, since they wrap in a bare MaterialApp(home: ...) rather than the
real app theme. That crash was latent since AppStatusColors was
introduced but only became reachable once main.dart actually started
using buildAppTheme() (previous commit), which is when 27-47 tests
started failing.

Fixed at both call sites (slot_selector_row.dart, day_status_calendar.dart)
by falling back to AppStatusColors.dark instead of force-unwrapping --
production code should never trust every context to fully replicate the
real theme. Also fixed day_status_calendar_test.dart, which asserted
against stale hardcoded Colors.* values from before the theme rewrite;
it now wraps in the real buildAppTheme() and asserts against the actual
tokens, so the assertions mean something again.

Verified: flutter analyze clean, 436/436 tests pass (up from 389/47).
2026-07-22 16:52:13 +02:00
0e9acb9e18 Wire theme.dart into the app; replace remaining raw Colors.* literals
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.
2026-07-21 21:50:16 +02:00
26f95d42f5 Fix stale adaptive_icon_background left over from the warm-palette icon regen
pubspec.yaml's adaptive_icon_background is a separate literal from
python_pkg/app_icons' own style.BACKGROUND -- the icon regen updated
every generated PNG but missed this one, so the actual on-device home
screen icon (Android's adaptive icon, not the legacy mipmap) stayed on
the old cool charcoal. Caught on the physical device, not by file checks.

testsAndMisc's generator now syncs this automatically going forward.
2026-07-21 21:42:32 +02:00
2a772d624f app: warm-tint the shared palette, fix Snack chip + on-fill contrast
- theme.dart: warm unified-design-system palette, on-fill for
  onPrimary/onError, and inputDecorationTheme/dividerTheme (previously
  unset -> every TextField/Divider fell back to Material defaults).
- slot_selector_row.dart: the Snack ChoiceChip had no explicit
  backgroundColor/side/labelStyle like the hour chips, so it fell through
  to Flutter's own default -- a literal pure-white border.
- Regenerated launcher icon assets (warm charcoal + warm gold accent) via
  python_pkg/app_icons.
- Bump gatelock pin to the released v0.1.1 carrying this same palette.

Verified: flutter analyze clean, diet_guard pytest 443/443 @ 100% coverage,
phone-installed APK pixel-checked (Snack chip border, on-fill contrast).
2026-07-21 21:16:26 +02:00
3bb21c00d6 Add a design-rules audit against the new safe-design-rules checklist
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
Report-only findings for a future cleanup pass; no code changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:25:20 +02:00
d631f8a141 Add change explanation: desktop-web-build
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:27:42 +02:00
50f311b6b8 Gate the companion app in the push hook, and document it properly
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
`ci_mirror.sh` -- the pre-push gate -- ran only the Python side: a clean-venv
pytest and `pre-commit --all-files`. Nothing ran the app's suite, and
`.pre-commit-config.yaml` has no Dart hooks at all, so a red `app/` could be
pushed with a fully green gate. It now runs `flutter analyze` and
`flutter test` too, and *fails* rather than skips when `app/` exists but
flutter is missing: a gate that quietly passes without its toolchain is the
same as no gate. Verified by breaking a Dart file on purpose and watching the
push gate refuse it.

app/README.md was still the stock "A new Flutter project" template. It now
says what the two targets are, why there is no `linux/`, what the wrapper
owns, why the port and Chrome profile are fixed, and which behaviours the
desktop genuinely gives up.

Drops MIGRATION_PROMPT.md, whose content now lives in CLAUDE.md and the
migration commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:01:40 +02:00
2231850bfc app: test the IndexedDB stores instead of shipping them unverified
The IndexedDB stores are the desktop device's primary copy of the food log and
the *only* copy of its photos -- photos never sync -- and their wrapper-mirror
fallback is what makes a wiped Chrome profile recoverable. All of that was
invisible to the suite: the files import `idb_browser.dart`, which cannot be
imported outside a web compile, so they were simply absent from the VM test
run rather than covered.

Splitting the store classes from the browser-factory `open…()` functions lets
them run against `idb_shim`'s in-memory backend on the plain VM, which is what
the flutter-desktop-to-chrome skill recommends the shim for. Twelve tests now
pin the round-trip, the mirror write, the recover-from-mirror path, and the
no-wrapper-running case (a plain browser tab, where the app must still work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:56:15 +02:00
c12faa3920 app: package the desktop app as the web build, and delete the Linux embedder
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
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>
2026-07-20 16:32:24 +02:00
aeb9376391 app: add the desktop wrapper that serves the web build and fronts GitHub
A browser cannot touch the filesystem, so the desktop app needs a second
half. This local process serves the built web assets, owns an on-disk copy of
every document and photo blob the app stores, and performs the GitHub I/O the
page cannot.

Both GitHub responsibilities are load-bearing, not convenience:

- the device-flow endpoints (github.com/login/device/*) send no CORS headers,
  so a page cannot call them at all;
- proxying api.github.com server-side is what keeps the token out of the
  browser -- the page is told `{"status":"ok"}`, never the token itself.

The wrapper falls back to the PC gate's existing
`~/.config/diet_guard/sync_token`, so this machine needs no second setup.

Details learned the hard way in the two apps migrated before this one, kept
here deliberately: loopback-only binding (the endpoints overwrite files in
$HOME with no auth), `application/wasm` for .wasm or CanvasKit renders
nothing at all, a fixed port and Chrome profile because IndexedDB is keyed by
origin, and treating a browser exit inside ~5s as a handoff to an existing
window rather than a reason to stop serving. Mirror names are rejected unless
they are plain filenames -- a `..` segment would turn a mirror write into an
arbitrary-file overwrite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:31:47 +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
f6abf802c9 app: replace the stock Flutter launcher icon, add a Linux desktop icon
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
Adopt the shared icon family (python_pkg/app_icons in testsAndMisc): a
charcoal #1B1D21 field carrying a teal shield-and-cutlery glyph, teal being
this app's own colorSchemeSeed. The fork is paired with a knife on purpose -
a lone three-tine fork reads as a trident at launcher size.

Android gains the adaptive-icon layers the project never had, including an
Android 13 monochrome layer.

Linux needs separate handling: Flutter's GTK runner never sets a window icon,
so my_application.cc now calls gtk_window_set_icon_name and
install_desktop_entry.sh populates the hicolor theme from the pre-rendered
PNGs in linux/icons (committed, so the installer needs no image tooling).
StartupWMClass is the APPLICATION_ID rather than the binary name - verified
with xprop against a running window, where WM_CLASS is the GApplication id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:00:37 +02:00
ce68dcd82c Add change explanation: calendar-streaks-budget-sync
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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
2026-07-13 21:45:11 +02:00
957769ff0e Add budget-adherence calendar, streaks, and freely-editable synced budget
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
Both platforms gain a calendar tab/screen (green/yellow/red/not-logged
day coloring, logging/adherence streaks, year-to-date tally), and
tapping a day drills into that day's logged entries. This requires the
daily budget to become a normal, freely-editable value synced between
devices like the food log — replaces the previous chattr +i seal
(deliberately, per user decision) with plain last-edit-wins sync via a
new budget.json alongside food_log.json. The budget field defaults to
2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on
both the PC gate's History tab and the phone app's Calendar screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
2026-07-13 21:31:45 +02:00
09cf5b726a app: use crdt_sync's shared GitHubClient; drop the local copy
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
crdt_sync v0.3.0 adds canAccessRepo(), so the settings 'Test connection'
button uses the shared client and the local lib/services/github_client.dart
(+ its test) is deleted. flutter analyze clean, 299 tests pass. On-device
foreground check deferred: the active night curfew disables the app (a
phone-policy thing, not a code issue); build + install -r succeeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 23:54:03 +02:00
67bc98c7e0 app: offline backstop — connectivity-gated push on reconnect
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
Immediate push on log only helps when the device is online. If a meal is
logged while offline, the in-process auto-sync fails silently and the meal
waited for the next app lifecycle event. Now each log also enqueues a one-off
WorkManager task constrained to NetworkType.connected, so the log uploads as
soon as connectivity returns, without the app being reopened.

- background_sync_service.dart: backgroundSyncPush() loads SyncSettings and
  runs runSync, returning WorkManager's success/retry flag (true when nothing
  to push or the push succeeded; false to retry after a transient failure).
  Unit-testable half; the dispatcher branch and the one-off registration are
  integration-only (real background isolate) and coverage-ignored, matching
  the existing periodic check.
- log_meal_screen.dart: _enqueueSyncBackstop() registers the task (replace
  policy coalesces a burst of logs; linear backoff retries) from both log
  paths. No-op off mobile.
- background_check_service.dart: dispatcher routes the new task name.
- log_storage_service.dart: writeLog is now atomic (temp file + rename) — the
  background push's runSync can write the log in a separate isolate while the
  foreground app also writes, so a torn read must be impossible.

Verified on device (BL9000EEA0000102): airplane mode on -> log -> background;
airplane mode off -> the phone's remote food_log.json commit advanced within
~15s, app never reopened (also confirms the token loads in the background
isolate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
2026-07-11 19:11:54 +02:00
16ca5227f6 app: push immediately when a meal is logged
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
The phone only pushed its log on screen-init, app resume/pause, or manual
"Sync now" -- never on the log action itself -- so a meal could sit locally
for many minutes before reaching GitHub (and thus the PC gate). Fire the
existing best-effort _autoSync right after a meal is logged, from both the
quick-log path (_onLogMeal) and the meal-builder return (_onBuildMeal), so an
online device propagates a new meal within seconds.

_autoSync is already single-flight and swallows offline/transient failures, so
this is a fire-and-forget unawaited call with no new machinery. Offline logging
still waits for reconnect + a lifecycle event (a WorkManager backstop is a
future follow-up).

Verified on device (BL9000EEA0000102): logging a meal advanced the phone's
remote food_log.json commit within seconds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
2026-07-11 18:55:06 +02:00
8a9c7713b9 Review fixes: specific sync exceptions, clearer names
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
Address code-review feedback on the sync-before-lock change:

- pull_shared_log now catches (SyncError, GitHubSyncError, OSError) instead
  of a blanket `except Exception`. crdt_sync wraps every requests transport
  error in GitHubSyncError, so those three are the whole realistic failure
  surface; the gate still fails closed on all of them, and a genuine bug now
  surfaces instead of being hidden as "sync unavailable". Drops the
  broad-exception pylint disable and the per-file BLE001 ignore in pyproject.
- Rename _cli_gate._is_due -> _should_lock: too easily confused with the pure
  local predicate gate_is_due it wraps. The new name says what it decides.
- Rename the list `satisfied` -> `satisfied_slots` in _reconcile_after_fetch;
  it's a list of slots, not a boolean.

No behaviour change; 100% coverage retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
2026-07-11 18:33:43 +02:00
0976c84254 Add CI-mirror pre-push gate + requirements-sync check
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
Mirrors the CI-green safeguards rolled out repo-wide (see screen-locker):
- scripts/check_requirements_sync.py: pre-commit hook that fails if a
  package import isn't declared in requirements.txt. On rollout it caught
  a real latent gap — diet_guard/_mcp.py imports pydantic but it was only
  present transitively (via mcp); now declared explicitly.
- scripts/ci_mirror.sh: pre-push gate = requirements.txt-only venv
  (hash-gated) running `pre-commit run --all-files` + `pytest`, replacing
  the plain pytest-coverage pre-push hook.

Only the tooling files + the pydantic declaration are committed; unrelated
in-flight work in the tree is left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PMRRVVkeRyrSaaqueMZKG
2026-07-11 17:58:49 +02:00
be94b4e147 Sync before locking the gate + manual "Fetch from sync" button
The PC gate forced a re-log when a meal was already logged on the phone
but the 15-min sync had not yet propagated it. Two ways to close that gap:

- The gate now pulls the shared log right before it would lock -- and only
  when a lock is otherwise due, so the network cost is paid on the rare
  tick that matters. A meal already pushed by the phone then unlocks
  without re-entry. Fails closed: any sync error (no token, network, disk
  write-back) falls through to the local lock decision, never skips it.
- A "Fetch from sync" button on the lock screen runs the same pull on
  manual request: it auto-unlocks when the pull satisfies every pending
  slot, advances to the next meal on a partial pull, or reports no change.
  Disabled in --demo so the sandbox never triggers a real sync/push.
- Both paths share one fail-closed wrapper, _sync.pull_shared_log(), which
  also keeps the single broad-except in one place.
- _write_log is now atomic (temp file + Path.replace): the gate and the
  15-min sync timer can now both write the log, so a torn read must be
  impossible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
2026-07-11 17:54:38 +02:00