progression_sync_service.dart client, record IO, JSON mapping
progression_sync_service_push.dart push local progression
progression_sync_service_pull.dart pull remote progression
progression_sync_service_session.dart the shared active session
Extensions are safe here: no test fake subclasses ProgressionSyncService, so
static dispatch cannot swallow an override the way it did for
WorkoutSyncService. Static members need qualifying from inside an extension
(ProgressionSyncService._stateToJson, .pathForExercise).
402 Dart tests; dart analyze clean; coverage gate passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
manual_workout.dart constants, the draft, buildEntry
manual_workout_validation.dart duration and per-sport rules
manual_workout_sync.dart sync payload + record id
manual_workout_budget.dart rolling budget accounting
This file is a cross-language contract: its JSON keys must match the Python
side's build_sync_payload / manual_sync_record_id. Nothing was retyped, and
both sides' fixtures still pass -- 16 Python wire-format tests and all 402
Dart tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
exercise_tile.dart 319 -> 144 + exercise_tile_rows.dart
github_mirror_screen.dart 441 -> 247 + github_mirror_screen_widgets.dart
home_screen.dart 383 -> 260 + home_screen_cards.dart
Same `part` pattern as history_screen: the private StatelessWidgets move, the
State stays. home_screen lands 10 over -- its remaining methods all call
setState, which an extension cannot, and its build() is one cohesive tree.
402 Dart tests; dart analyze clean; coverage gate passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
backup_service.dart (261 -> 175) + backup_service_session.dart
workout_sync_service.dart (415 -> 322) + workout_sync_service_read.dart
An extension method is dispatched STATICALLY, so a method a test fake
overrides cannot move to one: `_FakeSync extends WorkoutSyncService` overrides
pushManual and readMergedManualPayloads, and with those in an extension the
real implementations ran instead of the fakes -- four manual-workout screen
tests hung on pumpAndSettle rather than failing an assertion. Both are back in
the class; only the private _readMergedPayloads/_fetchPayloads/_mergeRecords
helpers moved.
That leaves workout_sync_service.dart at 322: its public surface is what
callers and fakes bind to, so going lower means changing that surface, not
moving text. Static members also need qualifying inside an extension
(BackupService._baseDir).
402 Dart tests; dart analyze clean; flutter coverage gate passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
402 Dart tests before and after. The cross-language wire-format group moves
with its siblings and still pins the same literal keys the Python side emits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This file is a flat list of testWidgets() calls with no group(), so the split
points are the test boundaries themselves. 402 Dart tests before and after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
Every top-level group() lands in exactly one file, each reusing the original's
imports and setUp/setUpAll preamble. 402 Dart tests before and after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
settings_screen_rows.dart section header, reps row, weight row
settings_screen_thresholds.dart stepper button, threshold card and row
Still over the cap: the remaining 503 lines are the State class, whose ~190
line build() would have to be broken up to go further. Two routes were tried
and reverted rather than shipped half-working:
- an extension holding the async actions -- rejected by the analyzer, because
setState is @protected and cannot be called from an extension. Worth
recording: it rules out moving any State method that calls setState.
- an extension exposing the SYNC/OFFLINE BACKUP rows as a widget-list getter.
That one is viable (those rows never call setState) but slicing a balanced
chunk out of a nested list literal is not a mechanical move, and this screen
ships to the phone.
402 Dart tests pass; coverage 100%; dart analyze clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
storage_service.dart the class: open, settings, active session
storage_service_schema.dart create / migrate / seed
storage_service_exercises.dart read+write a single exercise's row
storage_service_sessions.dart apply progression, record sessions
storage_service_backup.dart backup, restore, sync markers
Dart cannot continue a class body across files, so unlike history_screen these
are `extension`s in `part` files. Library privacy still applies inside a part,
so they reach `_db` and `_getSetting` unchanged and every caller still writes
`StorageService.instance.foo()` -- no public API change, nothing for
public_member_api_docs to demand.
Doing this exposed a bug in scripts/check_silent_failures_dart.py: its bare
`on Foo {` pattern (the catch clause form) also matched
`extension StorageServiceBackup on StorageService {`, so it reported the
declarations as swallowed exceptions. It now requires the try block's closing
`}` first. Verified both directions on a fixture: a real silent
`} on FormatException {` is still reported, the extension is not.
402 Dart tests still pass; flutter coverage still 100%; dart analyze clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
history_screen.dart the State: load, pick, build
history_screen_charts.dart picker, stats card, streak row, chart widget
history_screen_painter.dart the CustomPainter
history_screen_tiles.dart the three session tiles
history_screen_data.dart pure transforms over the loaded rows
`part` files rather than separate libraries, for two reasons. Dart privacy is
library-scoped, so these `_`-prefixed widgets cannot be imported across a
library boundary -- making them public would be a real API change and
public_member_api_docs would then demand docs for every one. And a `part` does
not get its own SF: entry in lcov, so the coverage gate's "every lib file must
appear" guard has nothing new to miss.
The data helpers become top-level functions taking `rows` explicitly, since
they only ever read it -- which is also what let them leave the State class.
402 Dart tests still pass; flutter coverage still 100% (2717/2717);
dart analyze clean under very_good_analysis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
The ci-mirror hook regenerates these on every run and then leaves them
dirty. Pre-commit stashes the dirty tree, the hook rewrites the same files,
and restoring the stash conflicts -- which rolled back the fixes and failed
every push regardless of whether the checks themselves passed.
Committing the hook's own output makes the tree clean, so there is nothing
to stash and the collision cannot recur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
Splits the settings screen: exercise thresholds/weights/reps/reset and the
offline-backup permission UI stay in place, Firebase+GitHub+Backup content
is replaced by a Sync settings ListTile using the shared sync_settings_ui
package. GitHub stays app-local as its own GitHubMirrorScreen (device-flow
connect, PAT field, verify-with-backup-recovery) since the shared package
carries no GitHub surface at all.
BackupSlot left unwired: there's no existing user-facing export/import
action for it to front (BackupService only runs from StorageService's
automatic write/startup paths), and inventing a manual import risked
clobbering current progression. Progression pull now fires unconditionally
after the Sync settings route pops (covers connect/disconnect/no-op alike,
since pullProgression already self-gates on remote-vs-local state).
365/365 tests passing, 100% line coverage, flutter analyze clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
Same pattern as todo and diet_guard_app: openFirebaseWithGoogle()
(google_platform.dart abstracts the plugin's platform-channel
dependence), isFirebaseConfigured() reading the stored session rather
than the account marker alone, and the settings screen offering
"Sign in with Google" ahead of the password form.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
Progression and the in-progress session now live at
screen-locker-sync/exercise_state/<name> and .../active_session — siblings of
devices/, not inside it. Both workout readers (_workout_sync.py:113 and
workout_sync_service.dart:307) list only screen-locker-sync/devices, so a
progression record is structurally invisible to them and cannot be miscounted
as a workout, which would silently grant unlock credit. Keyed on exercise name
because a reinstall mints a new per-install uuid.
Startup no longer calls requestStoragePermission(): that threw the user into a
system Settings page on every launch, for a permission that is now genuinely
optional. It only checks; the grant moved to an OFFLINE BACKUP section in
Settings, its sole caller.
The clobber guard keys on a persisted progression_synced_at marker, NOT
looksFreshlyInstalled(). The only production caller (_finishWorkout) writes a
workout_history row and last_workout_type BEFORE it pushes, so a freshness test
was already false by then and could never fire — a reinstall that connected
Firebase late (the uninstall wipes the keystore, so startup's pull finds no
account) would have overwritten every real remote record with factory defaults
on its first finished workout. Connecting in Settings now pulls immediately,
which closes that window. The same marker stops a pull reverting a deliberate
setExerciseWeight/resetExerciseToDefaults, which write no history at all.
Also adds a Dart silent-failure pre-commit hook. The Python one is
types:[python] and had never looked at a single Dart file — in the app where a
swallowed sync error costs the user a workout with nothing anywhere saying so.
It found 21 real silent swallows (keystore reads/writes, /sdcard mirror
failures, the HTTP bind, device-flow polls, and a .catchError((_) {})), all now
logging why.
Verified on the Pixel 6a: reinstall + deny storage + connect late restored
Bench 22.5→20.0, Row 22.5→25.0, Situp 30→31 reps with the remote copy
untouched; the RTDB device list stayed at exactly 3 entries and no progression
leaked into pull_synced_workout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG
A real StrongLifts session on 2026-08-10 never reached this machine, so it
had to be logged as a manual workout -- spending a manual budget slot on a
workout that was genuinely verified. The session was in Firebase the whole
time; four separate defects each hid it.
- pull_synced_workout() read a hardcoded devices/phone/log.json. Since
ffc6c72 the phone pushes under a per-install uuid, so that directory has
been frozen since 2026-07-17 and the PC kept returning a stale session
from 07-27 as if it were current. Both readers now walk EVERY device
directory; reinstalls keep minting new ones, so no single path can be
right for long.
- A session is identified by SHAPE (it has `exercises`), not by a `kind`
field the phone never writes. That filter is why the one reader that did
walk every directory discarded real sessions, and it also kept PC-origin
runnerup/phone_verified records from being handed to a caller expecting
a session.
- FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so
`except GitHubSyncError` never caught it and a DNS failure took the whole
locker down (status=1/FAILURE, then a restart loop). Catch the common
parent; a pull failure degrades to a warning.
- A GitHub token is no longer required to sync. Firebase has been primary
since eb4ff01, so a Firebase-only machine was reporting "sync is OFF"
while syncing fine.
Sync also only ran once per locker start, so a workout finished after login
was invisible until the next login. workout-sync.timer runs the new headless
--sync-only every 15 minutes; remove_systemd.sh now removes every unit the
installer creates, not just two of them.
workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds
factory defaults before the backup is restored, and the first write then
exported those defaults over the only off-device copy. Storage permission is
now settled before the DB opens, a defaults-only DB refuses to overwrite a
richer backup, and the three swallowed storage errors say what broke.
Sessions that exist only in Firebase/GitHub are restored into local history,
so a reinstalled app has the history it actually earned rather than starting
at whatever survived. Matched on (date, start_time), so re-syncing never
duplicates and local rows always win.
Adds a target-reps editor: progression can only ever RAISE reps, so a rep
target a defaults re-seed set wrong could not be corrected from the UI at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
A workout completed on 2026-08-09 never reached the sync repo and nothing
anywhere said why. The cause was structural: workout_screen.dart fired
`unawaited(WorkoutSyncService().push(session))` and `push()` caught ONLY
GitHubSyncError, reporting it via `debugPrint` -- which goes nowhere in a
release build. A Firebase error, an auth failure, a dropped connection or a
backgrounded app all produced exactly that symptom: silence.
This repo's CLAUDE.md already forbids it in as many words ("an invisible log
is a silent failure with extra steps", "return something meaningful"), so
this brings the Dart side up to the contract the Python side already meets
with `_manual_push.PushResult`.
- push()/pushManual() return a PushResult(pushed, reason) instead of void.
- The catch is deliberately broad, and logs at level 1000 (error) with the
stack trace via dart:developer, not debugPrint.
- Still never rethrows: a sync failure must not cost the user their finished
workout. The caller stays unawaited so a slow backend can't delay the
summary dialog, but it now logs when the push did not happen.
Tests assert the reason, not just that the future completes -- the old test
("a GitHubSyncError is swallowed, not rethrown") would have passed against
the broken behaviour.
Note this does NOT explain the 2026-08-09 miss retroactively: that session
ran at 12:35 UTC on the pre-migration build, ~3h before the new APK was
installed, so its failure is unrecoverable. It is in Firebase anyway via the
PC's independent verification (phone_verified:2026-08-09, "68 min, partial").
This makes the next one diagnosable.
314 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
The phone and PC each compute a nominally SHARED manual-workout budget over
the same merged records, and disagreed on both halves of it.
The cap: _constants.py has said 10 per 30 days since 298daf0; the Dart mirror
was still 5, so between 5 and 9 the phone hard-blocked the form for a workout
the PC would have accepted and credited. The stricter side wins, so this cost
logged workouts rather than granting extra ones.
The counting rule: count_in_window counts ENTRIES ("each logged manual workout
consumes its own budget slot"), while countManualBudget built a Set of date
strings and counted DAYS -- despite a docstring claiming it mirrored the PC.
Three workouts on one day read 3/2 on the PC, over its 7-day cap, but 1/2 on
the phone, which kept accepting. Both now report 3 for that input. Per-entry is
the correct semantic because each entry separately earns weekly-count and
shutdown credit, so each must separately cost budget.
The wire fixture could never have caught either: the caps and the counting rule
never travel on the wire. The comment claiming it was "the tripwire that
catches drift" was wrong and is corrected. The real guard,
TestCrossLanguageBudgetConstants, parses manual_workout.dart and compares it to
the imported Python constants -- one test that fails on EITHER side's drift.
Asserting a Python constant against a Python literal would have stayed green
through 298daf0, which is the drift that actually happened.
Also: the history volume chart summed targetWeight x targetSets x targetReps,
so a session where every set failed plotted identically to one completed in
full -- the line only moved when progression changed the target. It now sums
weight x doneReps over the logged sets. On today's real session that is 2790 ->
1440 kg, because 5 sets of bench press were logged at 0 reps and were being
counted at full credit. No targetWeight fallback per set: weight is
non-nullable and always serialized, and substituting the target would overstate
a set the user never did.
design.md/dfesign_v2.md: record that the Crucial (progress lost on exit) and
High (sound + vibration) items are done, that breaks are still per-set, and
that the warmup fraction shipped as 4/5 -- neither the 2/3 nor the 3/4 the
specs asked for, and 4/5 is what is wanted.
Verified on the phone (release build, signed with the shared key, installed
with adb install -r so the workout history survived); 907 Python and 313 Dart
tests pass with the coverage bar green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4y93ZSnk2w5LH3B12LHNc
screen_locker pushed manual workouts as "pc" and the workout app as "phone".
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.
Both halves now mint a uuid once and persist it: the PC in
~/.local/share/screen_locker/.device_id, the app under SharedPreferences
'crdt.nodeId' -- the same key every sibling app uses.
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 rather than
re-downloaded and re-merged as a peer's on every tick. It drops to None once
that path is reclaimed.
Note the *read* loop in _workout_sync deliberately still merges every device
including this one: the PC needs its own manual workouts in the merged view,
so "skip own" would be wrong there. Only the push path and the HLC node id
move.
The test fixture redirecting the new id file is added in the same change that
introduces the constant -- without it the first test that syncs mints a uuid
into the real state dir and this machine's live sync identity is decided by
whichever test ran first. Verified: ~/.local/share/screen_locker/ held no
.device_id after a full suite run.
Verified against the live backend, not just mocks: ran the real
push_pc_workouts, which minted 57582b3b and pushed 9 records. In the RTDB the
new device's log is a strict superset of both old paths -- 0 of devices/pc/'s
10 records and 0 of devices/phone/'s 9 are missing from it.
Python: 907 tests, 100% branch coverage. workout_app: 313 tests, analyzer
clean. Pins bumped to crdt-sync-v0.6.0 / crdt_sync_dart-v0.7.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
The last app on the list. The Firebase backend has been wired up since the
cutover commit, but nothing ever called saveAccount(), so openFirebase() read
an account that was never written, returned null every time, and the app
quietly synced over GitHub forever -- on real hardware, whatever the tests
said. Settings now has a Firebase section that stores the account and signs
in immediately, so a wrong password shows on screen instead of as a silent
no-op later; a rejected sign-in clears the account rather than leaving it
half-stored.
The worse half was in workout_sync_service. push(), pushManual() and the
read path each began `if (!settings.isConfigured) return;` -- true only when
a **GitHub token** exists. The MirrorStore that makes Firebase primary was
built *after* that check, so a phone connected to Firebase alone silently
dropped every workout it logged and reported no history, with no error
anywhere. Once the GitHub mirror is retired that would be every device. All
three now accept either backend.
UI follows the same shape as the other apps: GitHub demoted into "Advanced
(GitHub mirror)" (with the PAT fallback folded in, rather than two sibling
"Advanced" disclosures), and a connected account rendered as read-only text
with a Disconnect button instead of an empty-looking form.
The keystore accessors are injectable, like firebaseFactory already was, so
the connect/reject/disconnect branches are testable without a platform
channel. 302 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
Both surfaces of this repo move together, because they share one prefix:
screen_locker (the PC, which reads what the phone logged) and workout_app (the
phone, which writes).
Firebase becomes the primary backend when it is set up, with GitHub kept as a
mirror so a device that has not moved yet is still seen. MirrorSyncClient reads
the union of both, so a workout logged against either backend still counts --
which is what lets the PC and the phone cut over on different days.
The PC's two pulls are read-only, so they need only the client swapped.
_manual_push does a real sync_log and now carries a revision cache, as does
every push in workout_app: without it each push re-uploads the whole log and
each pull re-downloads every peer's, regardless of change.
remote_client is public rather than _private because _manual_push imports it;
one decision point beats two copies that can drift.
The config file is checked before constructing anything, so an unconfigured
machine never reaches the network -- without that guard the PC's sync tests
asserted against live Firebase data instead of their own fakes, which is how
this was caught.
screen_locker: 904 tests at 100% branch coverage.
workout_app: 299 tests at 100% line coverage; pinned v0.1.0 -> v0.6.0 across
three minor versions with no API drift. Four assertions moved off `.single`,
which now finds two writes: the log, then the revision published after it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
screen-locker 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.
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
Replaces the dead ColorScheme.fromSeed(Colors.indigo) (declared but
never consumed by any screen/widget) with theme.dart: an explicit
dark ColorScheme plus a real AppStatusColors extension, since this
app genuinely uses success/warning status colors throughout (unlike
dufs-cloud's app). Routes all 10 screen/widget files' ~280 hardcoded
Colors.*/fontSize literals through it. Fixes the two flagged
DESIGN_AUDIT_TODO violations: home_screen's Resume/Start buttons now
use a real 2x horizontal:vertical padding ratio, and rep_circle's
BoxShadow (the one shadow in an otherwise flat dark UI) is removed.
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.
_ui_widgets.py's _button() always set fg=self._colors.fg (near-white)
regardless of bg, so any accent/success/warning/danger-filled button
under-contrasted (measured 2.2-3.6:1 vs the 4.5:1 floor). Added
_fg_for(bg) so fg is derived from bg instead of hardcoded -- fixes every
current _button() call site structurally, not per-instance. Found and
fixed 2 more standalone instances of the same bug outside the shared
helper (_window_setup.py's demo-close button, _heat_skip.py's heat-skip
button).
Bumps gatelock to the released v0.1.1 carrying the same on_fill token
plus the warm palette rework. Regenerated workout_app's launcher icon
assets to match.
Verified: gatelock 50/50 + screen_locker 859/859 tests, both 100%
coverage; pre-commit (incl. pylint against the real v0.1.1 install) green.
flutter_launcher_icons writes the file without one, which trips the
end-of-file-fixer hook on push.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopt the shared icon family (python_pkg/app_icons in testsAndMisc): a
charcoal #1B1D21 field carrying a crimson barbell glyph. Crimson rather than
this app's indigo seed, because dufs_client already owns indigo and the two
sit side by side in the launcher; crimson is the app's own accent colour
(Color(0xFFFF4444)), so the icon still matches the UI.
Adds the adaptive-icon layers the project never had, including an Android 13
monochrome layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The active session (which set you are on, and the reps done so far) lived
only in app-private SQLite. An uninstall — or `pm clear`, which sounds
harmless and is not — wipes that, so a user standing mid-workout loses the
exact set and rep they were on. `backup.json` did not help: it mirrors only
COMPLETED history. This is not hypothetical; it happened, mid-workout, and
the state had to be re-entered by hand.
Persistence granularity was already right (saveActiveSession fires on every
set tap, untap, warmup and break change, storing `tapped` + `doneReps`).
Only the LOCATION was wrong, so this mirrors it the same way the sync token
already is:
- BackupService.exportActiveSession/readActiveSession keep
/sdcard/WorkoutTracker/active_session.json; a null export clears it.
- StorageService.saveActiveSession mirrors on every write;
loadActiveSession falls back to the mirror and re-seeds the table when it
is empty — exactly the state after a wipe; clearActiveSession clears the
mirror too, so a finished workout is never resurrected.
- Both reads are defensive: a truncated/non-object mirror (battery death
mid-set) is ignored rather than bricking the resume.
Tested by simulating the wipe in the suite (resetForTesting + fresh init
with the mirror intact), not on a real device holding real data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
SyncSettings.load() prefers the keystore and only consults the external
backup when the keystore is EMPTY. So a stale keystore entry — one left by
an earlier authorization — shadows a perfectly good backup indefinitely:
every sync 401s and the user is told to re-authorize for nothing. This is
not hypothetical; it is the most likely explanation for the phone's 401,
whose token turned out to be healthy (GET /user 200, scope repo) once read
from the backup.
- SyncSettings.recoverFromBackup(rejectedToken) re-seeds the keystore from
the backup and returns the recovered token, or null when there is nothing
to recover (no backup, empty, or the same token that was just rejected).
- settings_screen retries verification once via the backup before demanding
re-auth, and says so ("recovered the saved token from backup") instead of
nagging.
- workout_sync_service retries the read once from the backup, so history
self-heals without the user having to visit Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
Document the now-public ServerAddressTile.addresses field, wrap the prod
DB-path ignore in a start/end block to avoid an >80-char line, and reflow
a pre-existing >80-char comment in sync_service. flutter analyze is clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
Add live-workout interaction tests: set-tap breaks + Skip, warmup break,
rep decrement recompute, long-press reset, in-workout threshold change,
finish -> summary dialog, reset-confirm clears the session, and restoring
an in-progress break through to _onBreakFinished. Interaction taps run
under runAsync so their sqflite writes/break timers stay on the real loop.
coverage:ignore the Android/iOS-only Vibration.vibrate call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
Add an exercise drill-down test (dropdown select -> streak stats card,
weight chart, exercise calendar nav, session tiles with warmup/reps
variants) and a same-date test for the chart painter's tRange==0 branch.
coverage:ignore the _load exercise-state branch (unreachable — selection
goes through _pickExercise, not _load).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
Add tests for the threshold selectors (success + fail persistence), the
debounced weight write, and three device-flow branches that were untested:
token-save failure (throwing secure storage), verification failure, and
the transient pending "verifying" status tile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
Cover the post-navigation reload paths (return from workout via Resume,
return from settings), the resume-button closure, and the empty-address
placeholder. Expose ServerAddressTile so its no-address fallback can be
unit-tested directly (the test host always has network interfaces).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
Migration (v1->v3), reps-increment at weight cap, and restore-from-backup
paths were untested. Add targeted unit tests plus a file-db test hook
(resetForTesting dbPath) so onUpgrade actually runs. coverage:ignore the
prod DB path and the defensive null-state branch (unreachable — seeding
guarantees a state row).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
Add widget tests for the manual-workout icon navigation, starting a workout
(navigates to WorkoutScreen), and auto-resume of a saved active session.
home_screen down to 4 uncovered lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Pre-existing coverage gaps, unrelated to the manual-workout feature, blocking the
100% pre-push gate:
- widget_test: exercise WorkoutApp's app-lifecycle handler (paused/detached/
resumed/inactive/hidden) via handleAppLifecycleStateChanged.
- http_server_service: add a resetForTesting seam and test the bind-in-use
SocketException swallow plus the /workout HTTP endpoint (no-data 404, data 200,
unknown-path 404). coverage:ignore the /sdcard-file-read branch (only reachable
on a real device).
- backup_service: coverage:ignore the Android permission wrappers and the
defensive readSyncToken catch.
- main(): coverage:ignore the platform-channel app bootstrap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
getExternalStorageDirectory() is unavailable on the Linux test host, so the
Platform.isAndroid fallback blocks in sync_service/http_server_service can't be
exercised in CI. Wrap them in coverage:ignore (honored by flutter test
--coverage) so the 100% gate doesn't demand coverage of an Android-only path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
The phone can now log an off-app workout that syncs to the PC and counts toward
the unlock.
- lib/screens/manual_workout_screen.dart: the form, mirroring the PC manual
form (sport TT/other + sport-specific fields, times, location/transport/cost/
reservation phone, RPE slider, techniques/warm-up/pain, reflections). Shows
the shared 2/week + 5/month budget (pull-only, computed over the merged
cross-device record set) and refuses to submit when exhausted. On submit it
validates with validateManualWorkout, builds the shared sync record, and
pushes it to devices/phone/log.json.
- workout_sync_service.dart: pushManual(record) and readMergedManualPayloads()
(pull-only budget read — never writes).
- models/manual_workout.dart: ManualBudget + countManualBudget over the merged
payloads, windowed by each payload's own date.
- home_screen.dart: a "Log manual workout" app-bar action.
All new lib code is 100% covered (screen widget tests via an injected fake sync
service; sync-service MockClient tests; model unit tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Dart port of the Python manual-workout model so the phone can build the same
signed-on-ingest sync payload the PC form produces.
- lib/models/manual_workout.dart: ManualWorkoutDraft (mirrors the Python
dataclass), validateManualWorkout (ports validate_manual_workout: required
fields, HH:MM end-after-start, min duration, RPE range, per-sport rules,
reflection length), buildEntry/buildSyncPayload (byte-for-byte equivalent of
the Python build_entry + kind/date), manualSyncRecordId, buildManualRecord.
The MANUAL_WORKOUT_* constants are inlined here (a const-only file has no
coverable lines and trips the 100%-coverage file-presence guard), replacing
models/manual_workout_constants.dart.
- test/models/manual_workout_test.dart: the cross-language wire fixture (an
identical literal to test_manual_workout.py::TestSyncWireFormat — this catches
key-name/format drift between the two serializers) plus full validation
coverage. 29 tests; manual_workout.dart is 100% covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Pre-existing failures unrelated to the manual-workout feature, exposed once the
pre-push flutter gate started running the Dart suite:
- http_server_service / sync_service: getExternalStorageDirectory() throws
UnsupportedError (an Error, not an Exception) on non-Android platforms, so the
`on Exception` fallbacks didn't catch it and it escaped, failing start() and
writeWorkoutResult(). External storage is Android-only, so guard the call with
Platform.isAndroid instead of trying to catch a platform Error.
- sync_service_test: the "updates HttpServerService" assertion expected compact
JSON ("workout_type":"A") but the payload is pretty-printed
("workout_type": "A"); fixed the expectation to match.
Full workout_app suite (191 tests) now passes on this host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Pre-existing: any DB call (saveSession/getExerciseState/setExerciseWeight) made
directly in a testWidgets body runs in the fake-async zone and hangs sqflite-ffi
for ~3-4 min, then crashes the isolate ("Cannot close sink while adding stream"),
aborting the whole file. This blocked the pre-push flutter gate for any Dart-
touching commit. Two fixes:
- Wrap every in-body DB call in tester.runAsync(() => ...) so it runs on the real
event loop (setUp already does; the passing tests did their reads inside the
pump helper's runAsync). history/home/settings screen tests.
- StorageService._open: open the test ':memory:' DB with singleInstance:false so
each test gets a fresh empty DB. sqflite's default singleInstance returned the
same cached in-memory DB and resetForTesting never closed it, so rows piled up
across tests and pushed the asserted widget off-screen ("Found 0 widgets").
Production keeps the default single shared instance.
188 workout_app tests now pass (was aborting after ~4 min). Unrelated to the
manual-workout feature; needed to unblock its Dart commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
First phase of syncing phone-logged manual/off-app workouts to the PC so they
count toward the unlock. This phase only establishes the cross-language wire
contract; no behavior change to existing flows.
- _manual_workout.py: build_sync_payload(draft, date) = build_entry fields +
a `kind="manual_workout"` discriminator + the workout's own `date`; and
manual_sync_record_id(date, start) -> stable `manual:<date>T<HH:MM>` id.
A StrongLifts session payload has no `kind`, so an ingesting PC can route on
it and never mistake a manual for a verified session.
- test_manual_workout.py: TestSyncWireFormat pins the exact literal payload as
the cross-language contract (the Dart workout_app test will reproduce the
same literal byte-for-byte; neither side's own round-trip test catches drift
from the other).
- workout_app/lib/models/manual_workout_constants.dart: hand-maintained mirror
of the 7 MANUAL_WORKOUT_* constants + the kind value (Python stays source of
truth; the shared fixture is the drift tripwire).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Sync the workout log across devices using the shared crdt-sync /
crdt_sync_dart CRDT transport (same scheme as diet_guard).
- Python: new _workout_sync.py + _temperature_status_mixin.py; wire sync
into screen_lock.py / status_view.py; add the crdt-sync git dependency
and test_sync_fixtures / test_workout_sync suites.
- Dart (workout_app): new workout_sync_service.dart, github_device_auth.dart,
sync_settings.dart with tests + fakes; settings/workout screens updated.
- docs: workstream-d-shared-crdt-transport.md.
Verification: pytest 747 passed at 100% coverage; flutter analyze clean;
flutter test passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
Adds a rate-limited manual-workout entry point (StatusWindow + locked
retry screen) for activities like table tennis that ADB/RunnerUp can't
verify, gated by a 2/7d + 5/30d budget and a detailed evidence form
rather than free text. Full credit toward the weekly minimum, debt
clearing, and commitment prompt, matching phone/RunnerUp verification.
Also stops tracking personal runtime state (workout_log.json,
sick_history.json, extra_benefits_state.json, scheduled_skips.json,
shutdown_base.json, early_bird_pending.json) in git — these are
regenerated locally and never belong in a public/private repo's
history.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPcvAvt1JhiRzEyPDE5dA
- Add sqflite_common_ffi + very_good_analysis; tighten analysis_options
- Add BackupService for JSON export/import of exercise state
- Add full test coverage: models, screens, services, widgets
- Add scripts/check_flutter_coverage.sh to enforce 100% line coverage
- Add docstrings to ExerciseState fields and storage service
- Minor fixes across screens, widgets, and sync/HTTP services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuiPt6GPWkxpLbJFrnfy8U
- Refactor RunnerUp verification: extract RunnerUpDbMixin (_runnerup_db.py),
split _scan_and_fill_week_runnerup into a helper _try_fill_runnerup_for_date
to keep cyclomatic complexity ≤10
- Generalise TCX lookup to any date in the ISO week (was today-only); all gap
days Mon→today auto-filled on every startup and 08:30 timer firing
- Add _adjust_shutdown_time_by(): +1h per extra workout beyond the 4-workout
minimum, capped at midnight (hour=24)
- Add _shutdown_base.py: daily reset of shutdown config to a stored base so
the bonus doesn't silently accumulate across days
- Add _extra_benefits.py: streak tracking, skip credits (earn (n-4) credits
for 5+ workout weeks), early-bird extension to 09:00 for eligible weeks
- Add --status mode (_status.py): non-locking CLI view showing per-day
breakdown (✓/✗), RunnerUp auto-scan, bonus status, shutdown time, streak,
skip credits, and early-bird status
- Hook carrot into _check_non_verify_exits: bonus applied whenever auto-fill
pushes weekly count above the minimum
- Pass all pre-commit hooks (ruff, mypy, pylint, bandit, shellcheck,
codespell, max-file-length); 508 tests at 100% branch coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017auyHmf2ZwQcDAwXaSo7KX