The rest banner used to be inserted into the column only while a break ran,
so its arrival took height from the exercise list, shifted every tile down
and pushed the last one into scroll range. The strip is now a permanently
reserved 64 dp slot (muted "Rest / 00:00" while idle, Skip keeps its space),
the list is a non-scrolling column that scales down as a whole if it would
not fit (0.96 on the Pixel 6a with four tiles, pinned by a widget test at
the phone's logical size with the status bar simulated), and the activity is
locked to portrait. Verified on the real phone: only the tapped circle's
pixels change when a rest starts.
Verifying that needed a workout screen that today's already-logged workout
did not offer, so the app gained a `sandbox` product flavor beside `daily`:
com.kuhy.workout_app.sandbox installs side by side with its own data, refuses
every network connection at the socket layer, starts no LAN server for the
PC to pull from, keeps its /sdcard files beside the daily build's, wears a
red SANDBOX ribbon, and offers a SANDBOX section in Settings (wipe, mark
today not done, rest length in seconds). MainActivity answers the flavor and
carries a `WorkoutSandbox` logcat trace of every tap, break, save and
navigation. On Linux the same mode is WORKOUT_SANDBOX=1, driven under Xvfb by
scripts/run_sandbox_xvfb.sh. CI builds --flavor daily; .phone-deploy-flavor
tells phone_deploy.sh the same.
Set circles and the home icon buttons carry semantics labels so they can be
addressed by element (and by screen readers).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
The between-set break never finished once the phone's screen was off. Three
independent causes (worked through on-device on 2026-09-12; left
uncommitted awaiting the go-ahead, committed here as-is plus one pin bump):
1. The countdown was not wall-clock: `_breakRemaining--` on a
Timer.periodic, which Android throttles and Doze suspends in a
backgrounded isolate, so the counter stalled and _onBreakFinished()
never fired. Now a BreakClock deadline on the wall clock.
2. An expired break was silently dropped on restore (`if (remaining > 0)`
only) — now cued and logged.
3. No notification infrastructure at all: no package, no
POST_NOTIFICATIONS / FOREGROUND_SERVICE / WAKE_LOCK.
Now: flutter_foreground_task runs the countdown in a service isolate that
survives BACK and the screen lock; flutter_local_notifications shows an
ongoing "Workout A · break m:ss / Next: …" notification with −1 rep / Done
actions and a "Break over" alert on its own channel (raw break_end sound,
vibration). Button presses go through a persisted BreakIntent queue the
screen drains on resume, so a press with the app killed is not lost.
Finish and Reset stop the service; a startup reaper handles the crash case.
Verified on the phone with the screen off and locked: service alive and
ticking while Dozing, both notifications on the lock screen, channel
importance 5 with the break_end sound. Not verifiable from adb (receiver is
RECEIVER_NOT_EXPORTED): an actual action-button tap, and a true recents
swipe — see stronglift_replacement/TODO-workout-app-v2.md.
Also fixed en route: the break-end sound had never played in any widget
test (AssetSource needs path_provider, which nothing faked, and the failure
was swallowed by catchError); the queue's prune wrote back a stale list.
This commit additionally:
- takes the freshness gate's bump flutter_local_notifications 22.3.1;
- restores web/package.json to HEAD (the tree held a stale pre-bump copy);
- gives scripts/check_flutter_coverage.sh a FLUTTER_TEST_CONCURRENCY knob:
the default one-isolate-per-core run peaks over 4 GiB and is OOM-killed
under the resource cap. Default behaviour unchanged.
Gate: flutter analyze clean, 511 tests, 3187/3187 lines (100%), pre-commit
green. Not re-installed on the phone for the 22.3.1 pin: no device attached
at commit time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
Pre-push ci-mirror was red at HEAD on exactly these four; the lock was
regenerated with the pinned Flutter 3.47.4 (which also moves
flutter_secure_storage_platform_interface to 2.1.0 transitively).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
Pre-existing, and surfaced only because the pre-push CI mirror runs the
freshness gate over --all-files. Twelve findings across three ecosystems,
none of them related to the free-day work they were blocking.
Flutter: permission_handler 13.0.2, vibration 3.2.1, and
very_good_analysis ^11.0.0. The major analyzer bump newly flags 10
async_return_with_no_await sites across the Firebase, storage and sync
services; each is now `return await` rather than suppressed. Without the
await the future escapes the enclosing async function, so a failure
surfaces at the caller with a stack trace that no longer names the code
that made the call -- which is precisely the wrong behaviour in a sync
service whose errors are meant to be caught and retried locally.
npm: vitest and @vitest/coverage-v8 4.1.11 -> 5.0.0 (the pair has to move
together), plus eslint, typescript-eslint, globals, the @types packages
and user-event. The major vitest bump needed no test changes.
flutter analyze clean, 411 workout-app tests pass. vitest 52 tests pass
on 5.0.0, eslint clean, tsc --build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Python side: gatelock v0.7.1 -> v0.7.2 and crdt-sync v0.9.0 -> v0.9.1 (both
re-cut so consumers stop resolving pre-upgrade constraints), plus every `>=`
floor turned into an exact pin -- five majors among them (mypy 1 -> 2.3.1,
pylint 3 -> 4.0.7, pytest 8 -> 9.1.1, pytest-cov 4 -> 7.1.0, ruff 0.8 ->
0.16.5) and mcp pinned at 2.1.1. `scripts/ci_mirror.sh` builds a clean venv
from requirements.txt and runs the whole CI set: 1146 tests, 100% branch
coverage, no lint or type finding.
workout_app: sync_settings_ui v0.1.0 -> v0.4.1 and ten exact pins, with the
flutter_secure_storage 11 chain (compileSdk 37 / AGP 9.3.2 / Gradle 9.7.1).
411 tests green, analyzer clean, release APK builds.
CI's Flutter pin was 3.44.6 while `.fvmrc` says 3.47.2 -- the drift that made
kuhylog red on a commit that is clean locally. Both workflows now say 3.47.2,
and the gate reads that pin from now on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
The app said "manual budget exhausted for this window" (2/2w, 4/10m) while
the PC read 1/2w, 2/10m for the same records. The PC was right; two
independent Dart defects each inflated a window by one.
`_readMergedPayloads` never looked at `Record.deleted`, so the tombstoned
duplicate `manual:2026-08-21T21:00` -- the second of two logged on 08-21,
already deleted -- kept consuming a weekly slot. Suppression is collected
across the whole device union and applied after the merge, not per file:
one device tombstoning a record while another still holds it live must
delete it, and a per-file skip would just let the live copy win. This
mirrors the PC's `_tombstoned_ids`.
`countManualBudget` also counted `manual_workout:2026-08-15`, a bare
`{type, kind, date}` stub with no evidence fields. The PC refuses to ingest
those ("no workout data to ingest; skipping permanently"), so the phone was
charging budget for a workout neither device ever accepted. The filter lives
in the counter rather than the shared reader because `runnerup_verified` and
`pc_workout_verified` payloads legitimately carry no `start_time`, and the
history view must keep showing them.
Covered by a shared literal fixture both languages read, rather than two
independent round-trips: each side's own tests passed while the pair
disagreed, which is exactly how this shipped. Verified by reverting each
guard in turn and watching the twin fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
The rename commit staged the git mv but not the accompanying content
edit, so these TODO files landed without the "REMOVE ME AFTER FINISH"
line the md-naming gate requires. Repos whose hook was already live
refused that commit; repos without it accepted a file the gate would
now reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.
TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
The Flutter coverage gate blocked the push on 8 lines, all from the desktop
target's commit. Seven of them are one branch: didChangeAppLifecycleState
returns on its first line under Platform.isLinux, and the suite runs on Linux,
so the switch below it is unreachable from any test this repo can run.
Covering it would mean threading a platform seam through the nine
Platform.isLinux checks in this app for test reachability alone, so it is
marked instead -- the same treatment, and the same shape, as
storage_service_schema's platform edge.
The eighth was just untested: parseLockMode is a pure function, and it now has
the cases that matter, including the flag arriving next to --demo-escape.
main_lifecycle_test covers the observer's initState/dispose either way.
408 tests, 2759/2759 lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
14 tests failed with a RemoteSyncError from listDirectory, and the cause was
not in the screen they were testing. The sync guard short-circuits on "no
backend configured", but it asks that question two ways: the sync token AND
whether a Firebase account exists. The tests faked the token into secure
storage and stopped there, so on a machine holding a cached refresh token the
real factory answered yes, the guard opened, and the fetch escaped to the
network -- where flutter_test blocks HttpClient.
That went unnoticed until the Linux desktop target landed, because the desktop
build is what authenticates from the shared refresh token: the same tests pass
on the commit before it, on this machine, this minute.
So the fixture now pins firebaseFactory to "no account" by default, which is
what the faked token was already trying to express, and HistoryScreen takes it
as a tests-only seam next to the httpClient one it already had. The two direct
constructions in workout_sync_read_test.dart are pinned the same way.
402 Flutter tests pass and flutter analyze is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
Both previous escapes were unreachable, for the same underlying reason stated
two different ways. A Tk key binding cannot fire, because once the workout app
takes its seat grab X delivers every keystroke to that app alone. And "run
touch from another terminal" cannot be done, because the app covers the screen
with an override-redirect fullscreen window -- there is no other terminal to
reach. An escape has to be handled INSIDE the grab, by the app itself, or it
is not an escape.
So --demo-escape arms a key handler in the runner: Ctrl+Shift+Q ungrabs the
seat and closes the window. The grab, the override-redirect window and the
handoff are all unchanged, so the demo exercises the real locking mechanism
rather than a softened copy of it, and the hatch is the one deliberate way
out.
The handler ungrabs before closing. Destroying a window that still holds the
seat can leave the pointer and keyboard grabbed with nothing left to release
them, which needs exactly the reboot this hatch exists to prevent.
Not Escape, which the Dart side already binds for in-app navigation; shadowing
it would change the behaviour under test.
Verified in both directions against the real debug bundle, each under its own
Xvfb display: with the flag, the app exits on Ctrl+Shift+Q while holding the
grab and the lock takes the screen back; without it, the identical keystroke
does nothing and the app keeps the screen. A test asserts the production path
never passes the flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
The phone is lost, so no workout can be recorded and enforcement has been
disarmed since 2026-08-21. This gives the PC a first-class way to do the
workout, reusing the existing app rather than reimplementing the progression
rules a second time -- two implementations would guarantee the divergence the
"same weights everywhere" requirement forbids.
Storage: sqflite has no Linux plugin, so main() installs sqflite_common_ffi's
factory, which runs the same schema and migrations against the same SQL. It
was already a dev dependency driving the test suite; this only promotes it.
Auth: the account password in ~/.config/crdt-sync no longer authenticates
(verified: HTTP 400 INVALID_LOGIN_CREDENTIALS). The PC fleet has been running
on the cached refresh token all along, so the desktop credential store reads
that same 0600 file -- the Dart and Python credential JSON keys are already
identical -- and passes null instead of the stale password. This also avoids
google_sign_in, which ships no Linux implementation.
Lock mode: --lock-mode makes the window the lock surface. There is no Dart API
for this, so the runner sets override-redirect and takes an exclusive
gdk_seat_grab. Two details cost a debugging cycle each and are commented in
place: override-redirect must be set BEFORE the first map, or the window stays
mapped and never repaints; and gtk_window_fullscreen() only ASKS the window
manager, which cannot see an override-redirect window -- so the geometry is
set from the monitor directly, otherwise a strip of live desktop is left
uncovered at the edge of a larger screen.
The grab is acquired by retry rather than by asking the supervisor to release
first: the Python locker keeps its grab while this window maps on top, so the
screen is covered continuously instead of exposing the desktop for the whole
of Flutter's cold start.
Verified against the live data: pulls all 7 exercises from Firebase (Bench
17.5x12, RDL 27.5x8, Row 25x6, Situp 10x31 -- the real weights, not factory
defaults), leaves the remote progression byte-identical, and mints its own
device id rather than reusing the phone's. A Tk client is refused the grab
while the lock holds it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
workout-locker.timer (added in e5db254) re-runs the full lock-decision
chain every 30 minutes, but is_relaxed_day() is a pure date check with
no memory of an earlier same-day dismissal. Clicking "Skip — No
Penalty" only closed the window without persisting anything, so the
next tick re-derived reason=relaxed_day and rebuilt the same popup.
Now the skip button writes a relaxed_day_skip marker to the log
(excluded from weekly counting and sync, same treatment as heat_skip),
and the startup chain checks for it before re-entering the relaxed-day
branch. Also renames workout_log.json to log.json throughout the repo,
tests, docs, and Dart comments, since the fix touches that file anyway.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qH2hJ6mvCJd3ZXpAJSaF7
Last of the ten. The ~190-line `build()` was the bulk, so it became five
private StatelessWidgets rather than list-building methods, per the note in
the todo that a previous attempt to slice them out as a raw widget-list getter
produced unbalanced brackets.
settings_screen.dart 211 state, lifecycle, setState paths
settings_screen_sections.dart 190 SYNC, OFFLINE BACKUP, app bar
settings_screen_exercise_sections.dart 161 WEIGHTS, TARGET REPS, THRESHOLDS
settings_screen_widget.dart 89 the public widget + 11 seams
settings_screen_actions.dart 86 setState-free actions
Every conditional in the new widgets is the one `build()` already had, in the
same shape: no defaulted parameters, no `??` fallbacks, no convenience null
checks. That was deliberate — the earlier workout_screen pass showed a single
added `if (x == null) return` guard is enough to drop the 100% gate, because
it is a line no existing test reaches.
`_openSyncSettings` split the way `_finishWorkout` did: the navigation half is
setState-free and moved to the actions extension, the post-return progression
pull keeps the setState and stays on the class.
`bash ~/utils/scripts/check_file_length.sh --all` now exits 0. 402 tests pass
with a byte-identical name set, dart analyze clean, coverage 100%
(2765/2765).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
Both by pattern B, both cut along the same line: whatever does not call
setState can live in a part extension, whatever does must stay on the class.
home_screen.dart 260 -> 228
home_screen_navigation.dart (42) takes `_openSyncSettings` and
`_openWorkout` — the two routes the screen pushes, each ending in the same
`unawaited(_load())` reload. `_load` and `_refreshSyncStatus` stay: both
setState.
manual_workout_screen.dart 313 -> 245
manual_workout_form_parts.dart (83) takes the form's non-interactive
building blocks — `_budgetBanner`, `_section`, `_text` — plus the
`_int`/`_str` field readers and the `_draft()` assembler they feed. The
sport dropdown and RPE slider stay on the class; both setState on change.
402 tests pass, dart analyze clean, coverage still 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
The one file on the list that could not be fixed by moving code, so the
approach was agreed with the user first: extract a real collaborator rather
than change the public surface.
`push`, `pushManual` and `syncNow` were three near-identical bodies — same
configured-or-bail guard, same GitHubClient/Firebase/MirrorStore construction,
same getFileText/mergeLogs/syncLog round-trip, same catch-everything-and-report
handler, same close-both finally. They differed in the record they merged in
(one, one, none) and two strings. That shared body is now
WorkoutSyncSession.run(), and the three methods are thin wrappers holding only
their own guard and their own wording.
Why a collaborator and not a `part` extension: extension methods dispatch
statically. Four fakes in _home_test_fixtures.dart override `syncNow`, and
manual_workout_screen_test.dart overrides `pushManual` and
`readMergedManualPayloads` — with those in an extension the real
implementations would run while the fakes sat unused, which is how four tests
hung on pumpAndSettle in an earlier attempt. All four remain ordinary methods
on WorkoutSyncService, so every override still binds.
`successReason` and `failurePrefix` are passed in rather than derived from one
another, so the exact strings the tests assert on ('pushed' / 'push failed',
'synced' / 'sync failed') stay under the callers' control.
Unlike the part files, this is a real library with its own lcov SF: entry, so
it has to carry its own coverage — it does. The total drops 2751 -> 2728
covered lines, which is the duplication the extraction removed.
402 tests pass; dart analyze clean; coverage 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
Last of the four test files. `_pump`, `_tapReal` and `_longPressReal` were
locals inside `main()`; they and the top-level `_completeSaved` / `_wrap` /
`_exercises` moved into _workout_screen_test_fixtures.dart.
workout_breaks_test.dart 201 breaks, long-press, finish,
thresholds
workout_screen_test.dart 141 render, buttons, restore
_workout_screen_test_fixtures.dart 73 the six hoisted helpers
Two mistakes worth naming, both caught by the test count rather than by a
failing assertion — a file that will not compile reports as
"loading <file>" and silently drops its tests:
- `_exercises` is referenced from a doc comment and a test body, so leaving it
private in the fixtures broke the load. It is `testExercises` now.
- The first cut landed inside the runAsync explanation comment that sits
between the last render test and the first interaction test, taking the
`setUp` closing brace with it. The comment explains why the interaction
tests need runAsync, so it moved to the breaks file where those tests are.
402 tests pass; name set gains only the new file's reporter lifecycle records.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
The file's four post-`main()` declarations were the blocker: FailingSyncService,
OkSyncService, HangingSyncService and CountingSyncService all
`extends WorkoutSyncService` and override `syncNow`. They moved to
_home_test_fixtures.dart together with the `_pump` / `_wrap` locals, so both
halves bind to one definition of each.
home_screen_test.dart 185 navigation, resume, start-workout
home_sync_card_test.dart 172 the sync-status card and its Retry tick
_home_test_fixtures.dart 66 pumpHome, wrapHome, the four fakes
Worth recording for the workout_sync_service.dart work still to come: these
four fakes are exactly why `syncNow` cannot move into a `part` extension.
Extension methods dispatch statically, so a subclass override of an extension
member never runs — the real implementation would, and the tests would hang
rather than fail.
402 tests pass; the name set gains only the new file's reporter lifecycle
records.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This is the file the previous session's splitter could not handle: `_pump`,
`_wrap`, `_seed` (and `_syncMock`) were local functions declared inside
`main()`. A textual split copies the preamble into both halves, and the second
half's references then resolve against the wrong closure — which is why the
file was left on the list rather than split badly.
Hoisted all four to top level in _history_test_fixtures.dart, renamed to
pumpHistory / wrapHistory / seedSession / historySyncMock, then split by
subject:
history_chart_test.dart 221 chart, drill-down, PC-synced rows
history_screen_test.dart 117 app bar, list, calendar, tile states
_history_test_fixtures.dart 99 the four hoisted helpers
None of the four closed over `main()` state, so hoisting is a pure move; the
runAsync/sqflite-ffi comment on `seedSession` travels with it, since that is
the non-obvious part a future reader needs.
402 tests pass. The name set gains only the new file's `(setUpAll)` /
`(tearDownAll)` reporter records.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
Same two-step shape as the workout-sync split, done in one commit because the
fixtures here are smaller and have a single consumer each.
_progression_test_fixtures.dart 101 FakeStore, ExplodingStore, seedRemote,
hlcAt, markSynced
progression_sync_push_test.dart 213 pushProgression, active session, result
progression_sync_service_test.dart 155 paths, pullProgression
`_ExplodingStore` was the file's one post-`main()` declaration — the thing
that kept it outside the splitter's envelope — and is now a fixture.
`markSynced` was a local function inside `main()`, so it moved too rather than
being copied into both halves where the two could drift.
402 tests still pass. The test-name set gains exactly two entries,
`(setUpAll)` and `(tearDownAll)` for the new file; those are framework
lifecycle records in the JSON reporter, not tests, and the +402 count is
unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
The largest file on the list. Now that the shared helpers live in
_sync_test_fixtures.dart, the two post-`main()` group functions were already
self-contained and became their own files rather than needing the splitter:
workout_sync_service_test.dart 195 push / pushManual
workout_sync_read_test.dart 238 readMerged*, token recovery
workout_sync_manual_survival_test.dart 174 Firebase-connect survival
workout_sync_cutover_test.dart 92 dual-write cutover
Each new file gets its own `TestWidgetsFlutterBinding.ensureInitialized()`.
That was previously supplied once by the shared `main()`, and
`installFakeSecureStorage` touches the test binary messenger — without it the
files would fail to load, which shows up as a dropped test count rather than
a red assertion.
402 tests pass and the test-name set is byte-identical to the baseline, so
nothing was lost, duplicated or silently skipped in the move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
Groundwork for splitting workout_sync_service_test.dart (740). The file's
shared helpers had to leave it first: `_cutoverTests` and
`_manualWorkoutSurvivalTests` are declared after `main()` and both reach back
into preamble helpers, so any split would have stranded one side of the file
from the fixtures it calls.
Moved and made public (a sibling import cannot see `_`-private names):
`_response`→syncResponse, `_fileContaining`→fileContaining, `_PutCall`→PutCall,
`_mockGitHub`→mockGitHub, `_session`→workoutSession, `_logPut`→logPut,
`_service`→syncService, `_fakeFirebase`→fakeFirebase.
`_manual` was a *local* function inside `main()` — the exact shape that broke
history_screen_test.dart last time, where both halves of a split get the
preamble copy but the second half's references resolve wrong. It is now the
top-level `manualRecord` in the fixtures file, so it survives the split.
Pure move: no test added, removed, renamed or reordered. 402 tests pass and
the test-name set diffs byte-identical against the pre-change baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
The largest lib/ file on the 250-line list and the one never attempted. It
has no private widget classes to lift out (pattern A did not apply), so the
cut is by responsibility:
workout_screen.dart 250 state fields, lifecycle, setState methods
workout_screen_session.dart 182 save/restore, break maths, threshold write
workout_screen_finish.dart 110 the write-everything finish step
workout_screen_body.dart 105 exercise list + break banner
workout_screen_dialogs.dart 73 finish/reset confirmations
workout_screen_appbar.dart 68 clock + Reset/Finish actions
workout_screen_breaks.dart 67 rest timers, threshold edits
Two constraints shaped it. `setState` is `@protected` and unreachable from an
extension, so methods that call it either stay in the class or split into a
class-side wrapper plus a setState-free body — that is why `_finishWorkout`
keeps only its three lines and delegates to `_persistFinishedWorkout`. The
breaks extension needs real state writes, so the class exposes one named
`_applyBreakState` shim rather than scattering the seam.
The extensions are named `_Foo`, not `Foo`: extending the private
`_WorkoutScreenState` from a public extension trips
`library_private_types_in_public_api`.
Coverage caught one real regression mid-way. Restructuring the threshold
update as a map-returning helper with an `if (s == null) return` guard added
a branch no test exercises and dropped the gate to 2753/2754. Rewritten as
`_writeThresholds`, mutating in place with the original `if (s != null)`
shape, so the extraction stays a pure move.
dart analyze clean; 402 tests pass with a byte-identical test-name set
(captured via --reporter json and diffed, since a count alone cannot tell a
lost test from a duplicated one); flutter coverage back to 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
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
- Remove automatic rest timer after each set
- Add inline threshold controls (success/fail streaks) on each exercise card
during an active workout
- Settings: auto-save on change with 600 ms debounce; replace Save button
with Reset to defaults
- Fix weight display asymmetry in settings (fixed 72 px width, centred)
- Progress screen (renamed from History): per-exercise view shows streak
counters, weight chart (kg Y-axis, date X-axis, rolling-2 avg for total
volume), exercise-filtered calendar, and per-exercise session tiles
- Total view shows rolling-2-session average volume chart + full calendar
+ all-session list
- Add WorkoutCalendar widget with monthly navigation
- Store warmupDone in ExerciseResult JSON; surface warmup per session tile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Breaks belong after each rep (circle tap), not removed entirely.
Restored break_banner, audioplayers, vibration, and break_end.mp3 asset.
Break triggers on every circle tap except the last one; 3 min on success,
5 min on failure; sound + vibration fires when the countdown ends.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full-featured workout tracker with session persistence, auto A/B cycling,
warmup weights (4/5 of target), settings weight stepper, history + progress
graph, HTTP sync server, and crash-safe active session resume.
Removed per-set break timers per user preference. Dropped audioplayers and
vibration dependencies; updated permission_handler to 12.x to eliminate
two of three KGP build warnings (shared_preferences_android is an upstream issue).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>