From 32a735f16887f7e19263e2130e1b5610a48aa824 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 01:04:56 +0100 Subject: [PATCH] Drop explanation pages and window titles textgate scan 319 -> 0: docs/explanations (dropped explain-diff output, not published, nothing links it), Tk window titles, the MaterialApp title, '+kg' -> '+' plus the weight icon, and spoken-only Semantics labels hoisted so the gate sees them. Co-Authored-By: Claude Opus 5.5 --- ...3-explanation-flutter-coverage-to-100.html | 464 ------------ ...xplanation-multi-workout-day-and-sync.html | 686 ------------------ ...18-explanation-check-phone-week-aware.html | 686 ------------------ docs/explanations/index.html | 51 -- screen_locker/_heat_skip.py | 2 +- screen_locker/screen_lock.py | 4 +- screen_locker/status_view.py | 2 +- scripts/demo_workout_handoff.py | 2 +- .../workout_app/lib/main.dart | 4 +- .../settings_screen_exercise_list.dart | 4 +- .../lib/widgets/exercise_settings_sheet.dart | 3 +- .../widgets/exercise_settings_sheet_rows.dart | 6 + .../lib/widgets/exercise_tile_rows.dart | 4 +- 13 files changed, 22 insertions(+), 1896 deletions(-) delete mode 100644 docs/explanations/2026-07-13-explanation-flutter-coverage-to-100.html delete mode 100644 docs/explanations/2026-07-17-explanation-multi-workout-day-and-sync.html delete mode 100644 docs/explanations/2026-07-18-explanation-check-phone-week-aware.html delete mode 100644 docs/explanations/index.html diff --git a/docs/explanations/2026-07-13-explanation-flutter-coverage-to-100.html b/docs/explanations/2026-07-13-explanation-flutter-coverage-to-100.html deleted file mode 100644 index 0a5c9ea..0000000 --- a/docs/explanations/2026-07-13-explanation-flutter-coverage-to-100.html +++ /dev/null @@ -1,464 +0,0 @@ - - - - - -Driving the workout app to 100% test coverage - - - -
- -CHANGE EXPLAINED · 2026-07-13 -

Driving the workout app to 100% test coverage

-

How a stuck push gate got unblocked — and the Flutter widget-test techniques that made the hard 267 lines of chart, calendar and live-workout UI testable.

- - - -

1 · Background

- -

The repo contains a Flutter phone app, workout_app, that tracks strength workouts and syncs them to a PC. To keep quality high, the project installs a pre-push gate: whenever a commit touches Dart code, pushing runs two checks locally before the push is allowed to leave your machine.

- -
-
git push → flutter analyze — zero issues (even info-level)
-
→ flutter test --coverage — 100% of every lib/ line, and every file must appear in the report
-
- -

That second check is strict in two ways. First, the bar is 100% — not 90%, not "the lines you changed". Second, a helper script (check_flutter_coverage.sh) also fails if any lib/**/*.dart file is missing from the coverage report, so you can't quietly earn 100% by never importing a file into a test.

- -

The catch: this gate had never once passed for a Dart commit. It was added while the app was only ~83% covered, so ten commits — an entire phone-to-PC sync feature — sat finished but unpushable, stuck behind 316 uncovered lines. This change closes that gap and ships all of it.

- -

Where the 316 lines lived

- - - - - - - - - -
FileBeforeAfterNature of the gap
storage_service.dart139/165163/163migrations, restore, edge branches
home_screen.dart117/121121/121post-navigation reloads
settings_screen.dart243/262262/262device-flow branches, steppers
history_screen.dart167/308307/307whole exercise drill-down + chart
workout_screen.dart144/270269/269live-workout breaks & finish
- -

The two screens at the bottom — the history charts/calendar and the live-workout timer UI — held 267 of the 316 lines and were the genuinely hard part. Testing them ran head-first into a subtlety about how time works inside a Flutter widget test. That's the idea worth understanding, so it gets its own section.

- -

2 · Intuition — two clocks, one test

- -

A normal testWidgets test does not run on real time. Flutter wraps it in a fake clock (FakeAsync): timers only advance when you explicitly say tester.pump(someDuration). This is usually wonderful — you can "wait" 3 minutes for a rest timer in a microsecond, deterministically.

- -

But the app's real database, sqflite, schedules work on real timers and background isolates. Under the fake clock, those never get a chance to finish. So the moment a tapped widget kicks off a database write, you get a landmine:

- -
-
-
Fake-async zone (default)
-

tap outside runAsync

-
tap a set → _tapCircle
-
↓
-
unawaited(_saveActiveSession()) → sqflite write
-
↓
-
write parks on a real timer the fake clock never ticks
-
↓
-
✗ "Pending timers" — test fails
-
-
-
Real loop via tester.runAsync
-

tap inside runAsync

-
tap a set → _tapCircle
-
↓
-
unawaited(_saveActiveSession()) → sqflite write
-
↓
-
real event loop runs the write to completion
-
↓
-
✓ no leftover timers — test passes
-
-
- -

So the rule that unlocked the live-workout screen is: any tap that triggers a database write or starts a Timer.periodic must be dispatched inside tester.runAsync(...), which runs that code on the genuine event loop where the work can actually finish. The assertion afterward happens on the fake clock again, so you still get deterministic expects.

- -

Two corollaries fell out of this while writing the tests:

-
    -
  • A dialog that awaits, then does DB work, is one flow. When you tap "Finish", a confirm dialog opens; confirming resumes _confirmFinish in the same zone the first tap fired in, and that continuation does six sqflite writes. So the whole finish flow — open dialog, confirm, save — has to live in a single runAsync block, not just the confirm tap.
  • -
  • tester.longPress fights runAsync. It does its own internal fake-clock pumps to time the press, which don't advance inside runAsync. The fix is a manual gesture: startGesture, hold for a real 700 ms, then release — a real-time long-press.
  • -
- -
-

▶ Micro-world: run the test yourself

-

Pick where the tapped code runs and what it does, then dispatch the tap. Watch whether the background work completes or leaks a pending timer.

- -
-
-
Dispatch the tap in…
-
- - -
-
-
-
The tapped code…
-
- - - -
-
-
- - -
-
1 · gesture dispatched, onTap fires
-
2 · handler runs…
-
3 · background work scheduled…
-
4 · test tears down, checks for leftover timers…
-
Choose options and press “Dispatch tap”.
-
-
- -

The other half: reaching the code at all

-

Zone discipline makes taps survive, but many lines were uncovered simply because the widget was never built. Flutter's ListView lazily builds only what's near the viewport, so the history screen's session tiles and the "DUMBBELL LUNGE" section label — which sit below an 800×600 test fold — never existed in the tree, and their lines stayed cold. The fix is to scrollUntilVisible / ensureVisible them first: scrolling builds them, which both renders and covers them.

- -

Honest 100%: what coverage:ignore is for

-

A few lines genuinely can't run on a Linux CI host, so they're marked coverage:ignore with a stated reason rather than faked. The bar for an ignore is "no reachable test path exists," not "this was annoying to test":

-
-
Android/iOS only
Vibration.vibrate() — hasVibrator() returns false on Linux (no Platform.isAndroid/isIOS), so the body never runs there.
-
Production-only path
The real on-device DB path (getDatabasesPath()) — tests always inject an in-memory or temp-file path instead.
-
Truly unreachable
A defensive null-state branch that seeding makes impossible, and a history _load branch only reachable via a path that never calls _load.
-
- -

3 · Code — what actually changed

- -

The diff is 792 insertions across ten files, but it splits cleanly into two buckets: a lot of test code, and a handful of tiny production edits that make code reachable from tests. Grouped by concern:

- -

A · Test seams in production code

-

To test the "server not started" placeholder, a private widget had to become public — private widgets can't be constructed from a test file:

-
-class _ServerAddressTile extends StatelessWidget {
--  const _ServerAddressTile({required this.addresses});
-+/// ...Public so the empty-address fallback can be exercised in a widget test.
-+class ServerAddressTile extends StatelessWidget {
-+  const ServerAddressTile({required this.addresses, super.key});
- -

And to test the schema migration, resetForTesting gained an optional path so a test can hand it a real file DB (in-memory DBs can't be reopened at a new version, which is exactly what an onUpgrade migration needs):

-
-  static void resetForTesting() {
--    _testDbPath = ':memory:';
-+  static void resetForTesting({String dbPath = ':memory:'}) {
-+    _testDbPath = dbPath;
- -

B · Justified coverage ignores

-

Four small blocks are marked unreachable-on-CI, each with a comment explaining why — for example the haptics call:

-
    if (await Vibration.hasVibrator()) {
-+      // Android/iOS-only: hasVibrator() returns false on the Linux test host
-+      // (no Platform.isAndroid/isIOS), so this body never runs there.
-+      // coverage:ignore-start
-      unawaited(Vibration.vibrate(duration: 800));
-+      // coverage:ignore-end
-    }
- -

C · The tests — real behaviour, on the real loop

-

The bulk of the change. A representative live-workout test: a tap starts a rest break, and the tap is dispatched through a helper that keeps it on the real event loop so the sqflite write and periodic timer complete cleanly.

-
Future<void> _tapReal(WidgetTester tester, Finder f) async {
-  await tester.runAsync(() async {
-    await tester.tap(f);
-    await Future<void>.delayed(const Duration(milliseconds: 100));
-  });
-  await tester.pump();
-}
-
-testWidgets('tapping a set starts a break which Skip cancels', (tester) async {
-  await _pump(tester, _wrap());
-  await _tapReal(tester, find.byType(RepCircle).first);
-  expect(find.byType(BreakBanner), findsOneWidget);   // break started
-  await _tapReal(tester, find.text('Skip'));
-  expect(find.byType(BreakBanner), findsNothing);      // Skip cancelled it
-});
- -

And the schema-migration unit test that the new dbPath seam unlocked — build a v1 database on disk, open it through StorageService (which runs at v3), and assert the migration ran:

-
final oldDb = await databaseFactory.openDatabase(dbFile,
-    options: OpenDatabaseOptions(version: 1, onCreate: /* v1 schema */));
-await oldDb.close();
-
-StorageService.resetForTesting(dbPath: dbFile);   // real file, not :memory:
-await StorageService.init();                       // triggers onUpgrade 1 → 3
-
-expect(await _svc.getNextWorkoutType(), 'A');      // settings table now exists
-expect((await _svc.getExerciseState(name))!.successThreshold, 3); // column added
- -

D · Making analyze green

-

The gate also requires a spotless flutter analyze, and it exits non-zero on even an info. The last commit documents the newly-public field, reshapes the ignore comment so it isn't an over-long line, and reflows one pre-existing 81-char comment. Small, but the push doesn't go through without it.

- -

4 · Quiz

-

Five questions on the reasoning behind the change. Click an answer to reveal whether it's right and why.

-
-
Score: 0 / 0
- -
- - - - diff --git a/docs/explanations/2026-07-17-explanation-multi-workout-day-and-sync.html b/docs/explanations/2026-07-17-explanation-multi-workout-day-and-sync.html deleted file mode 100644 index bbe4379..0000000 --- a/docs/explanations/2026-07-17-explanation-multi-workout-day-and-sync.html +++ /dev/null @@ -1,686 +0,0 @@ - - - - - -Multiple workouts per day, the sync that never ran, and a ban on silence - - - - -

Multiple workouts per day, the sync that never ran, and a ban on silence

-

- feat/check-phone-runnerup-and-tray · 3 commits · 62 files · - +2719 / −777 · 2026-07-17 -

- - - -

1. Background — what this thing is

- -

- screen-locker is a discipline tool. A systemd user service runs - python -m screen_locker.screen_lock --production at login and again - from a timer at 08:30/09:05. If you haven't earned your workout, it throws a - fullscreen Tk window over everything until you do. If you have, it calls - sys.exit(0) and you never see it. That's why "the locker isn't - running" is usually the healthy state, not a fault. -

- -

- It decides using screen_locker/log.json — a gitignored, - HMAC-signed file. Each entry is signed with a key shared across screen-locker, - diet_guard and wake_alarm, so the log can't be quietly hand-edited to fake a - workout. Two things read it constantly: has_weekly_minimum() - (fire the lock or not?) and the reward path (how late may the PC stay on - tonight?). -

- -

- A workout gets in three ways. phone_verified — a - StrongLifts session pulled off the phone. runnerup_verified - — a real run, parsed from the actual TCX file that RunnerUp writes to - /sdcard/Documents/RunnerUp/ and pulled over ADB. And - manual_workout — a self-reported evidence form - (squash, table tennis), rate-limited by a budget because nothing verifies it. - That distinction — machine-checked versus self-reported — is the hinge the - whole change turns on. -

- -

- There's also a companion Flutter app on the phone, and a shared GitHub repo - (kuhyx/syncs) holding a small CRDT log per device, so the two - machines can agree on what happened. -

- -

The shape that caused everything

- -

- log.json was keyed by date, one entry per day. - The writer said so in as many words, and did exactly that: -

- -
# _log_mixin.py — before
-"""...The log is day-keyed: one entry per date."""
-logs[date] = entry          # a second workout that day OVERWRITES the first
- -

- So the file physically could not hold two workouts on one day. Everything - downstream inherited that: count_weekly_workouts iterated the date - keys, so "4 workouts a week" and "4 days a week" were the same - sentence. Nobody had to choose between them, because the data model had - already chosen. -

- -

2. Intuition — one slot per day

- -

- The trigger was mundane. On Monday 13 July, kuhy played squash and went - for a 9.8 km run. He logged the squash by hand. The run — a real, verified, - TCX-backed run — had nowhere to go. The day already had its slot. -

- -
-
-
-
Before — one slot
-
2026-07-13 →
-
squash (manual)
-
- the 9.8 km run would overwrite this -
-
-
-
After — a list
-
2026-07-13 → [
-
- squash run 9.8 km -
-
]
-
- both kept, both counted -
-
-
-
- -

The trap: a list alone is a cheat code

- -

- Letting a day hold many workouts sounds harmless until you notice what the - lock is for. If every entry counted, you could type four - manual entries on a Monday morning, satisfy "4 - workouts this week", and never be locked again until Sunday. The whole tool - would be an honour system with extra steps. -

- -

- So the rule isn't "count workouts". It's count what can't be - faked: -

- -
- The weekly rule. Each verified - workout counts individually — several in one day all count, because a - TCX file and a StrongLifts session are machine-checked. - manual entries count at most once per - day, however many you write. Self-reports can't inflate the total. -
- -

- Play with it. Add workouts and watch the count and the lock: -

- -
-
- A week (Mon–Thu shown). Weekly minimum is 4. -
-
-
- - - -
-
-
- -

The second trap: the same workout, twice

- -

- Once a day can hold many workouts, "is this one already here?" stops being - free. Every entry now carries a workout_id — deliberately the - same string the sync layer uses as its record id, so a workout logged - on the PC and the same workout arriving from the phone collapse to one row - instead of two. -

- -
-
-
-
manual
- manual:2026-07-13T14:00 -
date + start time
-
-
-
runnerup
- runnerup_verified:2026-07-13 -
type + date
-
-
-
phone session
- startTime.toIso8601String() -
minted by the app
-
-
-
- -

The sync had never worked. Not once.

- -

- While fixing the above, a second thing surfaced. The PC was supposed to - publish its workouts so the phone could see them. It never had — - screen-locker-sync/ in the repo contained a single - .gitkeep. Two independent reasons: -

- -
    -
  1. - No token. push_pc_manuals() opened with - if token is None: return — a bare return, no log line. The PAT - had been saved to the wrong app's config directory. The code knew and said - nothing. -
  2. -
  3. - A side-store. It pushed from manual_sync_log.json, - written only at log-time. So anything recorded before that feature existed — - like the squash — could never publish, no matter how many times it ran. -
  4. -
- -

- The fix for (2) also fixes a category error: derive the push from - log.json itself. The log is the truth; the sync is a - projection of it. Then history publishes automatically, forever, with no - second place to fall out of date. -

- -

Why silence was the real bug

- -

- Notice the shape of the failure. Not a crash — a return []. During - the debugging session, pull_all_manual_records() returned - 0 and it was impossible to tell whether the repo - was empty or the call had failed, because the token guard was silent and real - errors logged at INFO. The answer only came from bypassing the - function and hitting the API by hand. -

- -
- An invisible log is a silent failure with extra steps. That's - now a rule with teeth: scripts/check_silent_failures.py, a - pre-commit hook. Every except must re-raise or log at - warning+. It found 27 violations across 16 modules - on its first run — and flagged its own source file too. -
- -

- Worth knowing why a custom AST rule was needed at all: ruff already runs with - select = ["ALL"], so S110 (except: pass) - was already enforced — and the codebase had none. The lethal pattern - wasn't pass. It was except: log.info(...); return [], - which looks handled and no ruff rule catches. -

- -

3. Code — the change, by concern

- -

3.1 One reader, two shapes

- -

- A new dependency-free module is the single place that reads the file. Old and - new shapes both load, so a half-migrated file — or a rolled-back writer — is - never a crash. Crucially, wrapping a legacy dict in a list leaves the - signed bytes untouched, so every existing HMAC still verifies with - zero re-signing. -

- -
+ # screen_locker/_log_io.py  (new)
-+ def load_workout_log(log_file) -> dict[str, list[dict]]:
-+     normalized = {}
-+     for date, value in read_raw_log(log_file).items():
-+         if isinstance(value, list):
-+             normalized[date] = [e for e in value if isinstance(e, dict)]
-+         elif isinstance(value, dict):
-+             normalized[date] = [value]      # legacy: wrap, don't rewrite
-+     return normalized
- -

3.2 One writer, which appends and dedups

- -

- The overwrite becomes an append behind a single chokepoint that returns a - result instead of None — so the caller can tell a genuinely new - workout from a duplicate, and scale the reward accordingly. -

- -
- logs[date] = entry                       # overwrite
-+ workout_id = _derive_workout_id(date, workout_data)
-+ if workout_id is not None and any(
-+     _entry_workout_id(date, e) == workout_id for e in entries
-+ ):
-+     return RecordResult(appended=False, prior_entries=prior)
-+ entries.append(entry)
-+ return RecordResult(appended=True, prior_entries=prior)
- -
- This bit was written twice. The first version compared - e.get("workout_id") directly. Legacy entries predate that field, - so theirs is None — un-matchable. The moment the PC started - publishing history, the 08:30 timer ingested the squash back and appended a - second copy of it. It had been dismissed in the plan as a "known - minor, accepted" risk; it fired within the hour, on real data. - _entry_workout_id() now derives an existing entry's id - when it has none, so a legacy row matches its own synced twin. -
- -

3.3 The counting rule

- -

- The anti-gaming asymmetry from section 2, in the one function that both the - lock and the banked bonus consult: -

- -
for date_str, entries in load_workout_log(log_file).items():
-    ...
-    has_manual = False
-    for entry in entries:
-        wtype = entry.get("workout_data", {}).get("type", "")
-        if wtype in VERIFIED_WORKOUT_TYPES:
-            count += 1              # each verified workout counts
-        elif wtype == MANUAL_WORKOUT_TYPE:
-            has_manual = True
-    if has_manual:
-        count += 1                  # all of a day's manuals count as one
- -

3.4 The reward, with two guards

- -

- _apply_workout_credit now scales to the day: the first counted - workout earns the base +2h; an additional same-day - verified one earns +1h; an additional manual earns - nothing. The old "+1h when weekly_count > 4" gate was removed — - weekly volume is the banked bonus's job, and paying for it twice was the bug - hiding in plain sight. The credit fires only if the write actually appended - (id guard) and the entry is dated today (date guard) — so a - back-dated backfill raises the count but never pushes tonight's shutdown. -

- -

- The tests caught a real defect here: the unlock screen still labelled the +1h - #{weekly_count - 4}, which under the new rule can render - negative. -

- -

3.5 The sync, derived from the log

- -

- Record id = the workout's own workout_id. HLC = derived from the - entry's own timestamp — which makes a re-push byte-identical, so the - repo never churns. That determinism is what lets the push run on every startup - without turning the sync repo into a commit firehose. -

- -
+ hlc = Hlc.new_tick(_PC_DEVICE_ID, wall_time_ms=_entry_wall_ms(entry, date))
-+ log[record_id] = Record(id=record_id, fields={_PAYLOAD_FIELD: (payload, hlc)})
- -

- And nothing about it is silent any more: -

- -
+ @dataclass(frozen=True)
-+ class PushResult:
-+     pushed: bool
-+     record_count: int
-+     reason: str        # always a sentence a human can act on
- -

3.6 Two recoveries, one pattern

- -

- The phone kept a Keystore token and mirrored it to - /sdcard — but load() only consulted the mirror when - the Keystore was empty, never when its token was - present-but-rejected. A stale entry could shadow a good backup forever - while the UI insisted all was well. Now a 401 triggers one retry from the - mirror before anyone is asked to re-authorize. -

- -

- The same pattern then closed a sharper hole. active_session — which - set you're on, and the reps done — lived only in app-private SQLite. A - pm clear wiped it mid-workout, and backup.json - didn't help because it mirrors only completed history. The state had to - be re-entered by hand. The persistence granularity was already right (it saves - on every set tap); only the location was wrong. -

- -
-
-
-
app-private (wiped)
- SQLite active_session
- Keystore sync.token -
-
→
-
-
/sdcard (survives)
- active_session.json
- sync_token
- backup.json -
-
-

- On launch, an empty table re-seeds itself from the mirror. Set 4 of the - Romanian Deadlift, reps [8,8,8,8], survives an uninstall. -

-
- -

3.7 The rule that outlives the fix

- -
+ # scripts/check_silent_failures.py — pre-commit hook `no-silent-failures`
-+ def _handler_is_loud(handler) -> bool:
-+     """True if the handler re-raises or logs loudly about the failure."""
-+     return any(
-+         isinstance(node, ast.Raise) or _is_loud_log_call(node)
-+         for node in ast.walk(handler)
-+     )
- -

- There's deliberately no escape hatch, because noqa is banned - repo-wide. Two ironies for the record: the checker's own docstring contained - the literal string # noqa and tripped the noqa-blocker, and - _log_io.py — written for this very change — violated the new rule. - Both were invisible while untracked, since git ls-files hides - untracked files from the hooks, and only surfaced once committed. -

- -

4. Quiz

-

- Five questions on the reasoning. Score: 0 / 0 -

-
- - - - diff --git a/docs/explanations/2026-07-18-explanation-check-phone-week-aware.html b/docs/explanations/2026-07-18-explanation-check-phone-week-aware.html deleted file mode 100644 index 2f82422..0000000 --- a/docs/explanations/2026-07-18-explanation-check-phone-week-aware.html +++ /dev/null @@ -1,686 +0,0 @@ - - - - - -Check Phone learns to look backward, and forgives shaky GPS - - - - -

Check Phone learns to look backward, and forgives shaky GPS

-

- c277325 · 12 files · +583 / −78 · 2026-07-18 -

- - - -

1. Background — what Check Phone actually checks

- -

- screen-locker locks the screen until a weekly workout - quota is met. The status window has a voluntary "Check Phone" button - (screen_locker/_status_view_verify.py, - PhoneCheckMixin) that, on click, pulls real data off your - Android phone over ADB and — if it verifies — logs it and applies the - reward. It never trusts a typed claim; it always pulls the actual file - first. Two sources feed it: -

- -
-
-
-
StrongLifts
- Pulls workout_result.json off the phone. - _verify_phone_workout in - _phone_verification.py. -
-
-
RunnerUp
- Pulls the real .tcx file from - /sdcard/Documents/RunnerUp/. - _verify_runnerup_workout in - _runnerup_verification.py. -
-
-
- -

- Both of those functions were written to answer one specific question: - did you work out today? They match on today's date only — - StrongLifts by comparing the JSON's date field, RunnerUp by - grepping filenames for today's date string. That's the right question for - the locked screen, which decides whether to unlock right now. But - the voluntary "Check Phone" button reused those same today-only functions, - even though what it's really trying to answer is broader: is this - week's quota met? -

- -

- The quota itself, from _weekly_check.py, follows an - anti-gaming rule established in the previous change to this repo: each - verified workout (StrongLifts or RunnerUp) - counts individually, however many happen on one day, because they're - machine-checked and can't be faked. A manual - self-report counts at most once per day. The weekly minimum is 4. -

- -

- A week-aware scanner for RunnerUp already existed — - _scan_and_fill_week_runnerup walks every day from Monday to - today, and for each one still ADB-pulls and validates the real TCX file - before writing anything. It was wired into the automatic login check and - the --status CLI, but never into the "Check Phone" button. - Nothing equivalent existed for StrongLifts at all. -

- -

2. Intuition — a same-day blind spot, and a shaky GPS trace

- -

- Here's what actually happened. Wednesday's status window connected to the - phone and clicked Check Phone. The phone had a real RunnerUp run from - yesterday — a 4.9 km run recorded the evening before, - after the button's earlier check that day had already failed. The result: -

- -
- Phone check (not_verified): StrongLifts: Workout JSON is from - 2026-07-17, not today · RunnerUp: No RunnerUp activity found for today -
- -

- Both messages are literally true and both are useless. There is a - real, verifiable run — the button just never looked at any date but today. -

- -
-
-
-
Before
- StrongLifts today? no →
- RunnerUp today? no →
- give up -
-
-
After
- StrongLifts today? no →
- RunnerUp today? no →
- scan Mon…today for anything unlogged -
-
-
- -

- The week-scan fallback reuses the existing RunnerUp scanner and a new, - symmetric StrongLifts one — both still pull and validate the real file via - ADB, they just stop requiring the date to be today and instead - accept anything from Monday through today that isn't logged yet. -

- -

The second problem the first one uncovered

- -

- Testing this against the real phone surfaced a second issue: that 4.9 km - run should count, but MIN_RUN_DISTANCE_KM is 5.0. GPS traces - measure distance by summing straight-line hops between sampled points, so - tight turns, tree cover, and urban canyons make the recorded distance - consistently shorter than the run actually was. A real 5 km run can - legitimately log as 4.9. So a 5% tolerance was added — applied to distance - only, since duration comes from the device clock and has no such noise: -

- -
-
-
-
recorded
- 4.90 km -
-
×1.05 →
-
-
tolerant distance
- 5.145 km -
-
≥ 5.0 →
-
-
verified
- counts -
-
-
- -

- Play with the whole chain below — it mirrors - _verify_phone_then_runnerup's real order: StrongLifts today, - then RunnerUp today, then the week-scan fallback, using the same 5% - distance rule at every RunnerUp step. -

- -
-
- -
-
- - - -
-
- - - -
- - -
-
-
- - -
-
-
- -

3. Code — the change, by concern

- -

3.1 Splitting the date-check out of StrongLifts validation

- -

- _validate_json_data used to do two jobs at once: check the - date, then check the exercises/duration/succeeded content. The week-scan - needs the second job without the first, so it was split: -

- -
+ def _validate_json_content(self, data: dict) -> tuple[str, str]:
-+     """...no date check..."""
-+     if not data.get("exercises"):
-+         return "no_exercises", "No exercises found in the workout JSON"
-+     ...
-
-  def _validate_json_data(self, data: dict) -> tuple[str, str]:
-      - today = time.strftime("%Y-%m-%d")
-      - if data.get("date") != today: return "stale", ...
-      - if not data.get("exercises"): ...
-+     today = time.strftime("%Y-%m-%d")
-+     if data.get("date") != today:
-+         return "stale", f"Workout JSON is from {data.get('date')}, not today"
-+     return self._validate_json_content(data)
- -

- _validate_json_data — the live, today-only check used by the - locked screen's unlock chain — keeps behaving exactly as before. It's now - just a thin date gate in front of the shared content check. -

- -

3.2 A week-scan backfill for StrongLifts (new — nothing existed before)

- -

- RunnerUp's _scan_and_fill_week_runnerup can find multiple - unlogged days because RunnerUp writes one .tcx file per - activity. StrongLifts only has one workout_result.json - snapshot on the phone at a time, so its equivalent can only ever backfill - one day per call — that's not a limitation of the fix, it's what the data - actually allows: -

- -
+ def _try_fill_stronglifts_for_week(self, log_file: Path) -> int:
-+     if not self._has_adb_device():
-+         return 0
-+     data = self._pull_workout_app_json()
-+     date_str = data.get("date") if data else None
-+     if not date_str:
-+         return 0
-+     today = datetime.now(tz=timezone.utc).astimezone().date()
-+     week_start = today - timedelta(days=today.weekday())
-+     entry_date = date.fromisoformat(date_str)   # ValueError -> 0, logged
-+     if not week_start <= entry_date <= today:
-+         return 0
-+     status, msg = self._validate_json_content(data)   # no date gate
-+     if status != "verified":
-+         return 0
-+     ...
-+     if write_signed_entry(log_file, date_str, workout_data).appended:
-+         return 1
-+     return 0
- -

- write_signed_entry dedups by workout_id (here - "phone_verified:{date}"), so re-scanning an already-filled day - is a safe no-op — calling this twice in a row correctly returns - 1 then 0. -

- -

3.3 Wiring both week-scans into the button

- -

- The old return type was a bare 4-tuple, which had no room for "nothing - verified today, but something got backfilled." A small frozen dataclass - replaces it, and a new helper runs both scanners and applies whatever - bonus is earned — reusing the exact prev/new-count math the login - auto-check and --status already use, specifically not - routing through _apply_workout_credit, which always files - under today by design and would mis-date a backfilled entry: -

- -
+ @dataclass(frozen=True)
-+ class PhoneCheckOutcome:
-+     credited_type: str | None
-+     status: str
-+     phone_message: str
-+     runnerup_message: str | None
-+     week_fill_message: str | None = None
-
-+ def _backfill_week_and_apply_bonus(verifier) -> str | None:
-+     prev_count = count_weekly_workouts(verifier.log_file)
-+     filled = verifier._scan_and_fill_week_runnerup(verifier.log_file)
-+     filled += verifier._try_fill_stronglifts_for_week(verifier.log_file)
-+     if not filled:
-+         return None
-+     new_count = count_weekly_workouts(verifier.log_file)
-+     bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
-+     if bonus > 0:
-+         verifier._adjust_shutdown_time_by(bonus)
-+     return f"Auto-filled {filled} workout(s) from earlier this week." + (
-+         f" +{bonus}h shutdown time." if bonus > 0 else ""
-+     )
- -

- _verify_phone_then_runnerup calls this only after both - today-checks fail, and _on_phone_check_result branches on it: - a verified-today result still goes through _apply_workout_credit - as before; a week_fill_message is shown as-is, because the - backfill step already wrote the entry and applied its own bonus — running - credit again there would double it. -

- -

3.4 The RunnerUp GPS tolerance

- -
  distance_km = data["distance_m"] / 1000
-+ if distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) < MIN_RUN_DISTANCE_KM:
--     if distance_km < MIN_RUN_DISTANCE_KM:
-      msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
-      return "too_short", msg
-+ if distance_km < MIN_RUN_DISTANCE_KM:
-+     _logger.info("...within %.0f%% GPS tolerance — accepted.", ...)
- -

- RUNNERUP_DISTANCE_TOLERANCE = 0.05 lives in - _constants.py next to the minimum it adjusts. The log line on - a tolerance-assisted pass exists so a below-threshold-but-accepted run is - still visible in the logs, never a silent pass — the entry itself still - stores the true, un-inflated distance. -

- -
- Superseded 2026-08-14. The numbers on this page describe the - rule as it stood on 2026-07-18 and are kept for that history. The thresholds - and their shape have both since changed: duration and distance were - an AND here (30+ min and 5.0+ km); they are now an - OR — a run qualifies at - MIN_RUN_DISTANCE_KM = 3.5 (inclusive, still with the 5% GPS - tolerance described above) or at - MIN_RUN_DURATION_MINUTES = 40 (strict, no tolerance). The - 30-minute floor every run had to clear is gone, so a fast 5 km now - passes too. The interactive demo and quiz below still use the old 5.0 km - AND-rule. -
- -

3.5 The CLI bug this surfaced (unrelated file, same root cause)

- -

- Verifying the fix against the real phone produced a week with two - workouts on one day for the first time. That's when - _status.py's --status CLI printed - "Need 2 more to reach the minimum (4)" for a week that was - actually 4/4 — the GUI, which uses count_weekly_workouts - directly, showed the correct number. -

- -
- before_count = 0
-- for i in range(7):
--     ...
--     if _print_day_line(d, ...):
--         before_count += 1          # capped at 1 per day, regardless of
--                                     # how many workouts that day held
-+ for i in range(7):
-+     ...
-+     _print_day_line(d, ...)        # display only now
-+ before_count = count_weekly_workouts(log_file)   # the real anti-gaming count
- -

- before_count had been derived from _print_day_line's - boolean return — one checkmark per day, regardless of how many workouts - that day held. It happened to agree with the real count for years, because - until this change no day ever legitimately held two verified - entries. The fix makes the CLI compute the same weekly total the GUI - already used, so the two views can no longer disagree. -

- -

4. Quiz

-

- Five questions on the reasoning. Score: 0 / 0 -

-
- - - - diff --git a/docs/explanations/index.html b/docs/explanations/index.html deleted file mode 100644 index 13f70c5..0000000 --- a/docs/explanations/index.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - -screen-locker — change explanations - - - -
-
screen-locker
-

Change explanations

-

3 explanations · interactive walkthroughs of notable changes, newest first.

- -
Generated by the explain-diff skill. Open any card to replay its walkthrough.
-
- - diff --git a/screen_locker/_heat_skip.py b/screen_locker/_heat_skip.py index d22ecf2..9e83982 100644 --- a/screen_locker/_heat_skip.py +++ b/screen_locker/_heat_skip.py @@ -129,7 +129,7 @@ class HeatSkipMixin: # Use the root itself (not a Toplevel) so we can go fullscreen. # This window is destroyed before the main GateRoot is created. root = tk.Tk() - root.title("Extreme Heat") + root.title("") root.configure(bg=_COLORS.palette.bg) root.attributes("-fullscreen", True) root.attributes("-topmost", True) diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index cc6217a..b3bb420 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -168,7 +168,9 @@ class ScreenLocker( self._instance_lock.release() sys.exit(0) self.root.on_callback_error = self.on_callback_error - self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else "")) + # textgate: the window is fullscreen, so its title is never painted; + # was "Workout Locker" (+ " [DEMO MODE]"). Nothing matches on it. + self.root.title("") self.demo_mode = demo_mode self.lockout_time = 10 if demo_mode else 1800 self._lock: LockWindow | None = None diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index c2e2851..600ed56 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -221,7 +221,7 @@ def main(argv: list[str] | None = None) -> None: return root = tk.Tk() - root.title("Workout Status") + root.title("") root.configure(bg=_STATUS_COLORS.palette.bg) root.minsize(560, 200) diff --git a/scripts/demo_workout_handoff.py b/scripts/demo_workout_handoff.py index ae6ed06..d5a1272 100644 --- a/scripts/demo_workout_handoff.py +++ b/scripts/demo_workout_handoff.py @@ -130,7 +130,7 @@ def main() -> int: if workout_app_binary() is None: return 1 root = tk.Tk() - root.title("Workout handoff demo") + root.title("") # Local grab + VT switching enabled: every escape hatch stays open. config = LockConfig(mode="hard", grab=GrabPolicy(kind="local", disable_vt=False)) hooks = _DemoHooks() diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index 7bf7a71..3c1a02c 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -192,8 +192,8 @@ class _WorkoutAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return MaterialApp( - // Same proper noun as BreakNotificationText.channelName. - title: 'Workout Tracker', + // textgate: no title. Was 'Workout Tracker'; the recents card shows + // the launcher icon and the manifest label (android:label) already. debugShowCheckedModeBanner: false, theme: buildAppTheme(), home: const HomeScreen(), diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart index 3f8b0a8..4d32478 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart @@ -76,10 +76,12 @@ class _ExerciseSettingsRow extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final until = state.isPausedAt(DateTime.now()) ? state.pausedUntil : null; + // Spoken only: excludeSemantics hides the painted children from it. + final semanticsLabel = '${state.name} exercise settings'; return Semantics( container: true, button: true, - label: '${state.name} exercise settings', + label: semanticsLabel, excludeSemantics: true, child: InkWell( onTap: onTap, diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart index c546411..2ca0124 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart @@ -163,7 +163,8 @@ class _ExerciseSettingsSheetState extends State { maintainState: true, child: _SettingRow( // Was "+kg at [N] reps, restart at [M]". - label: '+kg', + label: '+', + icon: Icons.fitness_center, name: 'Top reps', value: _s.repsHigh, min: _s.repsLow + 1, diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart index 3d3cf42..48c3c32 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart @@ -14,10 +14,14 @@ class _SettingRow extends StatelessWidget { required this.suffix, required this.trailing, required this.onChanged, + this.icon, }); final String label; + /// Optional glyph painted right after [label] (e.g. `+` then a weight). + final IconData? icon; + /// Semantic name of the leading stepper. final String name; final int value; @@ -36,6 +40,8 @@ class _SettingRow extends StatelessWidget { return Row( children: [ Text(label, style: style), + if (icon != null) + Icon(icon, size: AppTextSize.label, color: style.color), const SizedBox(width: 8), _Stepper( name: name, diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart index f18fc4d..71f0279 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart @@ -28,10 +28,12 @@ class _ModeChip extends StatelessWidget { @override Widget build(BuildContext context) { + // Spoken only: excludeSemantics hides the painted children from it. + final semanticsLabel = '${state.name} progression settings'; return Semantics( container: true, button: true, - label: '${state.name} progression settings', + label: semanticsLabel, excludeSemantics: true, child: InkWell( onTap: onTap,