Compare commits

...

12 Commits

Author SHA1 Message Date
89e6eed105 Bump sync_settings_ui to 0.6.1
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / free-days-interop (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:28:23 +01:00
06b70ab312 Bump crdt_sync to v0.12.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 14:24:46 +01:00
10938ed012 Align pre-commit sickdays pin to 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:38:22 +01:00
f182453567 Add morning-session corner strip
Top-right always-on-top strip while the signed morning session is
in_progress: title, "Next: <step>", "Locks return at HH:MM".
wake-alarm-strip.path starts it on every file rewrite; it re-reads
the file each second, asks for a refresh every 30 s, exits 0 when
the session ends. No display/output: warning, exit 1.
Bump gatelock pin to 0.12.0 (corner_strip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:19:42 +01:00
537a3a79e4 Retry session refresh; sign next_step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:12:01 +01:00
672eaf8aba Add todo app git hooks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 13:56:22 +01:00
13f6e3f5d2 Use google_account_token for sign-in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 11:27:35 +01:00
5525ca6f95 Move TODOs to todo app
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 11:14:40 +01:00
58c4260b6e Pin focus-project 0.3.0, sickdays 0.4.0
Conflicting git-tag pins in the shared user site let the last install downgrade focus_project/sickdays (no harvest, no focus locker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:02:56 +01:00
6033e3815f Home: fit one screen, details behind tiles
Stakes and history move behind two tiles that open bottom sheets;
the page no longer scrolls at Pixel 6a geometry (home_fit_test).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWWcbzoov1KFZJSFcSs
2026-11-01 22:06:43 +01:00
18a304de55 Trim hardening TODO to real residue
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 22:02:50 +01:00
a51a67e2fe Remove done file-length TODO
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 22:02:11 +01:00
48 changed files with 1576 additions and 986 deletions

View File

@ -8,6 +8,7 @@ default_language_version:
default_stages: [pre-commit]
fail_fast: false
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
@ -105,10 +106,10 @@ repos:
# direct URLs for one distribution, so any pin that drifts from
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
# Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
@ -186,6 +187,30 @@ repos:
language: system
files: \.(md|markdown)$
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: system
stages: [post-commit]
always_run: true
pass_filenames: false
- id: todo-import-pr-merge
name: import PR Todo sections (pull)
entry: scripts/todo_hooks.sh import-pr post-merge
language: system
stages: [post-merge]
always_run: true
pass_filenames: false
- id: todo-import-pr-rewrite
name: import PR Todo sections (pull --rebase)
entry: scripts/todo_hooks.sh import-pr post-rewrite
language: system
stages: [post-rewrite]
always_run: true
pass_filenames: false
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
# so the question is mechanically answerable at all.
#

View File

@ -82,8 +82,18 @@ cutoffs, neither imports `crdt_sync`, neither talks to Firebase:
**A PC booted mid-morning** has a missing or yesterday's file until the
timer's `Persistent=true` catch-up lands, and that needs the network. Only
the enforce path, only inside 05:00–11:00, and only when the file is not
today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as
today's, both consumers retry for ≤ 180 s (5 s steps) and then proceed as
normal with a WARNING. Status paths never wait; tests pin the retry to 0.
The refresher itself retries an unreadable backend every 5 s for 150 s
(`REFRESH_RETRY_SECONDS`) before exiting 1 with a WARNING: a PC booted at
07:48 had no DNS for its first seconds, and the old single try left the next
chance at the 07:50 tick (2026-11-07).
**`next_step`** (signed with the rest): while the session is `in_progress`,
the label of the first leg not in the phone's `passed` map, else `null`.
Names and labels live in `_session_legs.py` and are pinned to the phone's
`SessionLeg` by `wake_alarm/tests/fixtures/session_legs.json`, asserted by a
Python and a Dart test.
## Known limits (v1, deliberate)

View File

@ -1,112 +0,0 @@
# Refactor TODO — enforce the 250-line file cap
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
> and say "do refactor_claude_todo". It is self-contained: everything needed to
> execute is below. Generated 2026-08-14 from a measured survey of every repo.
## Goal
Every file in this repo must be **at most 250 lines** — source, tests, and
prose (`.md`/`.txt`/`.rst`/`.tex`) alike — and must **stay** that way forever,
enforced by a gate that fails the commit, not by a note anyone can ignore.
Why: a file that cannot be read in one piece forces re-reads and partial edits,
which is the single largest avoidable cost in an LLM-assisted workflow. Aim by
churn, not size alone — refactoring pays where code is read and changed often
(Fowler, _refactoring economic benefit_).
## Scope in this repo
- **18 files** currently exceed 250 lines (of 73 eligible files).
- **6,553 lines** sit in violation; longest file is **478 lines**.
Exempt (do NOT split these):
- generated files — `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `**/l10n/generated/**`,
anything with a `GENERATED` header
- markup — `.html`, `.css`, `.scss`
- data files — `.json`, `.yaml`, `.csv`, wordlists and other data-ish `.txt`
(mean line length under 25 chars)
## Violations, highest ROI first
ROI = lines x commits in the last year. Work top-down; a long file nobody edits
has near-zero payoff and should not be first.
| lines | commits/yr | kind | file |
| ----: | ---------: | :--- | :------------------------------------- |
| 478 | 15 | code | `wake_alarm/_alarm.py` |
| 438 | 12 | code | `wake_alarm/tests/test_alarm_part2.py` |
| 435 | 12 | code | `wake_alarm/tests/test_alarm.py` |
| 358 | 8 | code | `wake_alarm/tests/test_alarm_part3.py` |
| 457 | 4 | code | `wake_alarm/tests/test_alarm_sinks.py` |
| 283 | 6 | code | `wake_alarm/tests/test_alarm_part4.py` |
| 329 | 4 | code | `wake_alarm/_alarm_view.py` |
| 405 | 3 | code | `wake_alarm/_audio.py` |
| 386 | 3 | code | `wake_alarm/tests/test_sync.py` |
| 371 | 3 | code | `wake_alarm/_sync.py` |
| 317 | 3 | code | `wake_alarm/_escape_flow.py` |
| 314 | 3 | code | `wake_alarm/tests/test_state.py` |
| 420 | 2 | code | `wake_alarm/tests/test_alarm_audio.py` |
| 351 | 2 | code | `wake_alarm/tests/test_smart_plug.py` |
| 341 | 2 | code | `wake_alarm/_sinks.py` |
_(3 further files over 250 lines not listed — re-run the survey for the full set.)_
## How to split
- **Python** — extract cohesive helpers into sibling modules; keep the public
API and imports stable.
- **Shell** — split into `lib/*.sh` sourced by a thin entry script. Keep
`set -euo pipefail` in each.
- **Dart / TypeScript** — extract widgets/components into their own files.
- **Tests** — split by test-group into sibling files
(`foo.test.ts` -> `foo.parsing.test.ts`, `foo.render.test.ts`). Coverage must
not drop.
- **Docs** — split into topic files under `docs/` with an index. For an
oversized `CLAUDE.md`, move detail into referenced docs so the
always-loaded part shrinks.
**Do not** game the cap: no one-lining, no deleting tests, no moving code into
an exempt extension, no `# noqa`-style suppressions.
## Make it permanent (required — this is the point)
A refactor without a gate silently regrows. Before this task is done:
1. Wire the shared gate `~/src/utils/scripts/check_file_length.sh` into this repo's
`.pre-commit-config.yaml` as a local hook. If the repo has no pre-commit
config, add a minimal one.
2. The hook checks **files in the commit** (not the whole tree), so unrelated
commits never break, and it **fails** — exit 1, not a warning.
3. No baseline file and no allowlist. Those are suppressions.
4. If this repo has CI (`.github/workflows`), add the same check there so it
also fails on push.
## Done condition
- `bash ~/src/utils/scripts/check_file_length.sh --all` from this repo root exits 0.
- The repo's own test suite and coverage bar are still green.
- `pre-commit run --files <changed files>` passes.
- A deliberately over-250-line test file, staged, makes `git commit` **fail**.
- For a deployed daemon/app: the entry point still actually runs.
## Verify
Run the suite, then the alarm entry point. The phone_app companion needs `phone-deploy` verification if touched.
## Why this sat untouched (added 2026-08-24)
This repo ran a LOCAL `scripts/check_file_length.py` with `MAX_LINES = 500`,
under a hook named "Max file length (500 lines)". It reported "gated" while
permitting files every other repo rejects -- so the cap looked enforced and
nothing ever failed. The hook now delegates to the shared 250-line gate in
`~/src/utils`, which reports 17 real violations.
Done condition is now an exit code, not a count:
```
bash scripts/check_file_length.sh --all # must exit 0
```
REMOVE ME AFTER FINISH

View File

@ -1,225 +0,0 @@
# Wake-alarm hardening — continue here
REMOVE ME AFTER FINISH
Phases 0–7 are committed (`4286fcb`..`f5eb7fc`). This file is the entry point
for the rest. **Full plan, with every measured finding:**
`~/.claude/plans/wake-alarm-still-does-witty-toucan.md` — read it before
starting; this file deliberately does not duplicate it.
## Done so far
| Item | State |
|---|---|
| 6 audio (HDMI silent) | **Done.** `_reactivate` wired into `WakeAlarm`; verified on the host — the HDMI sink appeared and became default. |
| 7 speakers | `DOCS-speaker-recommendation.md`. Prose only, done. |
| 9 refactor | `_alarm.py` 478→250. 12 new modules; see `DOCS-architecture.md`. |
| 11 failsafe | `FAILSAFE_SILENCE_SECONDS=300`, audio only. Escalation re-tuned to fit inside it. |
| 12 logging | JSONL + cross-process dedupe + derived correlation id. |
| 14 fans | Gone: code, script, sudoers entry, installed state. Three stale doc/message references cleaned up in phase 6. |
| 2 power button | **Done.** `_inhibit.py` (logind block, self-terminating, stale cleanup by pid via `/proc`) + `_alarm_session.py` fire-time record, re-fired by `_trigger` outside the catch-up window. |
| 10 vmbox | **Done.** `scripts/vmbox/harness.sh` + `DOCS-vmbox.md`. 8 cases, fails closed. Found 2 live bugs the host could not reach. |
| 5 curtains | **Done.** `_curtains.py` driver protocol + no-op default, called from `prepare_hardware()`. `DOCS-curtains-hardware.md` has the buy list. |
| 1/4 phone alarm | **Done, verified on the phone.** Five Kotlin files behind one MethodChannel + the Dart ring screen. Fires from Doze on time, wakes the screen over the lockscreen, rings through DND on STREAM_ALARM, and dismisses via an NFC tap + challenge. Confirmed by the user on the Pixel 6a, not inferred. |
| 3 room proofs | **Mostly done.** NFC works end to end: `NfcReader.kt` (hand-written), Settings → "Room proof" registers the tag, and a tap satisfies the gate. Steps+elapsed is the fallback. QR is specified, persisted and unit-tested but has **no scanner UI** — `mobile_scanner` is the droppable extra. |
## Remaining, in order
1. ~~QR scanner UI~~ — **cancelled 2026-08-30.** QR was deleted rather than
finished: it had never been reachable at ring time (no scanner package, no
CAMERA permission, `onQrScanned` had no caller), and a printed code is
photographable, so completing it would have added a bypass to the very gate
the same change was hardening. The dismiss path is now a strict sequence:
tag 1 → 25 steps + 2 min → tag 2 → 3 challenges.
2. **Item 8, mutual gating.** Phone half is now in place. This is the phase
that legitimately touches `_sync.py`, so it carries that split (see below).
3. **Item 13, black screen.** Different repo (`~/src/testsAndMisc/
linux_configuration`), host-only, blocks nothing.
### Verified end to end on the Pixel 6a, 2026-08-27
**The whole path is confirmed on hardware, by the user, not inferred:**
- Fires from Doze on time (22:03:01 for a 22:03 arm), registered by the system
as `RTC_WAKEUP` / "Next wake from idle".
- Wakes the screen (`Asleep`→`Awake`) and puts `AlarmActivity` on top over the
lockscreen, with the full-screen-intent notification posted.
- **Rings through DND** — `zen_mode=1`, player `state:started` on
`usage=USAGE_ALARM`/`STREAM_ALARM(4)`, `mutedState:none`. The STREAM_ALARM
claim is measured, no longer assumed.
- NFC tag registers (Settings → Room proof) and **a tap dismisses**:
`tag discovered: 0465dab3ce2a81` on the ring screen, challenge solved,
alarm silent.
- Step counter reaches the gate.
**Four defects were found only because it was run on the phone**, every one
invisible to the emulator (no NFC, no step sensor) and to the widget tests
(which mock the channel):
1. `AlarmActivity.onPause` called `NfcReader.stop()`. `enableReaderMode` is
per-activity but `disableReaderMode` is global, so anything briefly in
front of the ring screen killed polling for the rest of the ring.
2. `TYPE_STEP_COUNTER` is cumulative and on-change: it emits nothing until the
user has already walked, and the first emission was being eaten as the
baseline, so the screen sat at `0/25` forever. `TYPE_STEP_DETECTOR` is now
primary — one event per stride, no baseline to miss.
3. NFC does not poll while the ring shows over a locked keyguard
(`mScreenState=ON_LOCKED`). `requestDismissKeyguard()` is called on fire.
It does **not** silently unlock a secured phone — it raises the PIN prompt
— so "tap without touching the phone" was never achievable on a secured
device. Say that plainly; don't let docs imply otherwise.
4. The **flash** challenge hides its code after 4s, which is unsolvable when
the screen is reached by walking to another room. Removed from the phone's
rotation, with a test asserting no self-hiding challenge is ever shown.
**A process lesson worth more than any of them:** after hardware confirmed the
path worked, the next change bundled a MethodChannel restructure with a bug
fix and regressed both NFC and steps. The user caught it. When hardware has
just gone green, make the smallest possible delta on top of *that exact state*
— never a restructure and a fix together.
### Fully automatic, verified on the Pixel 2026-08-27
The alarm is no longer opt-in and can no longer be weakened:
- **Arms itself with zero taps.** Launching the app was enough —
`dumpsys alarm` showed `RTC_WAKEUP ... com.kuhy.wake_alarm_sync/.AlarmReceiver`
at `2026-08-28 08:00:00`, `window=0`, `exactAllowReason=policy_permission`.
- **08:00 daily is baked in** (`AlarmPolicy.baselineHour`, mirrored by the PC's
`FALLBACK_ALARM_HOUR`). There is no arm button and no cancel — the Kotlin
`cancel` handler is deleted, so the capability does not exist rather than
being one `invokeMethod` away.
- **Earlier-only ratchet, confirmed on-device.** Picking a later time was
rejected on the phone with "That is not earlier than 08:00. The alarm can
only be moved earlier.", and the armed alarm stayed at 08:00. An earlier
start is one-shot and resets after the alarm fires.
- **Daily watchdog** (`AlarmWatchdog`) re-arms if nothing is scheduled, and
`BootReceiver` re-establishes it because `setInexactRepeating` does not
survive a reboot.
- **PC watchdog:** `_trigger._clamp_to_baseline()` refuses any resolved alarm
later than 08:00 whatever its source, so a stale or tampered remote value
cannot move the alarm later through sync.
### DND: verified for priority mode, NOT for total silence
Measured on the Pixel: `STREAM_ALARM` is unmuted under **priority** DND
(`zen_mode=1`, mask `0x1a6`) and **muted** under **total silence**
(`zen_mode=2`, mask `0x9be`). The confirmed ring was priority DND. No audio
flag escapes total silence, so if that gap matters the fix is a zen-policy
check at arm time, not an audio change. Detail in `DOCS-phone-alarm.md`.
Two harness traps found while testing this, worth keeping:
- **Do not pass `--build-number` to a probe build** — it produced a
non-running APK.
- **The arm probe can register for TOMORROW.** It calls `schedule()`, then
`main.dart`'s `rearm()` recomputes `nextOccurrence`; if the armed minute has
passed by then the alarm lands on the next day. Always re-check the
registered `triggerTime` is today before waiting for a ring.
### Still unverified
- The **two-tag sequence** has not been walked end to end on the phone: only
one physical tag is registered, so every on-device run so far has fallen
through to the 5-minute bypass. Needs a second tag.
- The **locked-keyguard** dismiss was not run to completion: the confirmed run
was on an unlocked phone. The PIN-prompt path is implemented but unproven.
## Decisions already made — do not re-litigate
- Failsafe stops **sound only**; lock, challenge and workout gate persist.
- Gating is **phone first, then PC**, with a bounded escape: if sync is
unreachable for ~600 s the PC gate degrades to locally-solvable, logged
WARNING. Without that, a Firebase outage means you can neither dismiss nor
get to work.
- Room proof: NFC **or** QR qualifies. **Steps alone does not** — the phone is
stationary in the other room while you walk *to* it, so steps only accrue
after pickup and can be faked by shaking it in bed. Steps + elapsed time is
the fallback when both others fail. (I narrowed "any one satisfies" here;
say if you disagree.)
- Phone alarm: hand-written Kotlin behind one MethodChannel, **not** a plugin.
AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that
won't compile breaks the build outright. `STREAM_ALARM` is load-bearing —
it is what ignores DND and silent mode.
- Curtains: no hardware; pluggable driver + logging no-op default.
- Quality bar: 100% branch coverage, no new `# noqa` / `type: ignore`.
## Traps found in the phone session
- **The plan's `LogStore.changes` does not exist.** `crdt_sync_dart` has no
Stream at all. Re-arm therefore needs three explicit hooks — startup,
BOOT_COMPLETED, dismiss — and dropping any one makes the alarm ring once
and then silently stop forever.
- **The Android SDK was never missing, only unexported.** `~/sdk/Android/Sdk` has
`cmdline-tools`, `emulator` and `platform-tools`; only `adb` was on PATH.
Export `ANDROID_HOME` rather than installing anything.
- **A permission declared in the manifest is not a permission granted.**
Both `POST_NOTIFICATIONS` and `ACTIVITY_RECOGNITION` are runtime grants, and
each one missing silently breaks a different half of the dismiss path
(no full-screen intent / no step sensor). The AVD caught both because a
freshly installed app there sits at `importance=NONE`.
- **A debug probe entrypoint with its own UI proves nothing about the route.**
The first probe rendered its own widget, so the AVD run validated the Kotlin
and nothing about `/ring`. Point the probe at the real app widget.
## Traps that cost time this session
- **The 250-line gate fires on *staged* files.** Touching a file means
splitting it in the same commit. Still over (re-measured 2026-08-27, after
phase 6): `test_sync.py` 386, `_sync.py` 371, `test_smart_plug.py` 351,
`_sinks.py` 341, `_alarm_view.py` 329, `_escape_flow.py` 317,
`test_state.py` 314. Budget for it. `test_trigger.py` is now split and
under.
- **`morning_routine/_orchestrator.py:39` hardcodes `"wake_alarm._alarm"`**
(different repo, `~/src/testsAndMisc`). Any module-path change updates it in the
*same* commit. This exact omission caused a month-long silent outage. Keep
`main()` in `_alarm.py`.
- **Splitting a test file loses its fixtures.** `_make_mock_tk`,
`_block_real_tk` and `_block_extra_devices` live *after* the imports, so a
naive header-copy drops them and every test errors. Copy the whole region
before the first `class`. Also: a multi-line module docstring gets stranded
if you replace only its first line.
- **Moving a symbol breaks `patch("wake_alarm._alarm.X")` silently** — the
patch stops intercepting and the test asserts nothing. Grep the whole
`tests/` tree for the old target after any move.
- **Autoformat strips an import added before its first usage.** `struct`
vanished from the extracted `_tone.py`; ruff, mypy and pylint all passed.
Only running it caught the `NameError`. Add import and usage in one edit.
- **Never let a beep-loop test miss its rung.** A test that patches only one
of `_beep_soft/_medium/_loud` and waits for it will spin forever on the real
audio stack and hang the whole suite instead of failing. Patch all three.
- Run tests with `/usr/bin/python3`, not the dev venv (no `gatelock` there).
- **`vm run` does not propagate the guest command's exit code** — it reports
the VM's verdict. A gate built on it fails *open*. See `DOCS-vmbox.md`.
- **`_state.py`'s UTC `_today_str()` must NOT be flipped to local**, despite
what the plan says. `screen_locker/_wake_state.py` mirrors the helper and
reads the same `wake_state.json`; flipping it loses an earned workout skip
for any dismissal between 00:00 and 02:00 local. `_alarm_session.py` shows
the way around it: carry an aware `fired_at`, compare elapsed time, no date
key at all.
- **Do not split a file the current change does not already have to touch.**
The gate fires on *staged* files, so an oversized file only becomes your
problem once you edit it. Editing `_sync.py` merely to repoint an import
pulled its 371 lines into the changeset and cascaded into five new modules
and a broken tree before being reverted. Prefer putting a new constant in
its single consumer (as `_curtains.py` does) over touching `_constants.py`.
- **A `__main__` CLI extracted to a new module breaks its documented command
silently.** `python -m wake_alarm._sync --show` printed nothing and exited
0 — invisible to ruff, mypy, pylint and the suite alike. If a later phase
moves that CLI, run the documented command and check the *output*.
- **Ruff's autofix will gut a function** whose results are only printed —
it stripped both the assignments and the `print` from a vmbox check, and
every linter stayed green. Grep the file for its output call after any
autofix.
## Verify
```
/usr/bin/python3 -m pytest wake_alarm/tests/ -q # 455 pass, 100% cov
bash scripts/vmbox/harness.sh # 8/8 cases, exit 0
pre-commit run --files <changed>
/usr/bin/python3 -m wake_alarm._trigger --dry-run
/usr/bin/python3 -m wake_alarm._alarm --demo # real window; ask first
```
Done = every item above shipped, this file deleted.

View File

@ -1,63 +0,0 @@
# Hardware ideas: defeat a fully-powered-off phone
REMOVE ME AFTER FINISH
Both items below were scoped during the 2026-09-23 workday-stick session
(`DOCS-morning-session-pc.md`) and explicitly deferred out of scope for that
session: no existing firmware project and no owned wearable, both need a real
purchase before any code gets written. Written here instead of
`~/src/todo/BACKLOG.md` because that file is gitignored/export-only from the
todo app and a manual edit there is silently lost on the next export — this
is the durable copy.
## Item 1 — second wake-alarm device: rings independently of the phone's power state
### what
An ESP32 + buzzer that polls the same Firebase alarm/session record
wake-alarm already reads and keeps ringing until it sees a dismiss or a
started session — so powering the phone off can no longer silently kill the
whole wake attempt the way it does today.
### where
new repo (no existing ESP32/PlatformIO/Arduino project anywhere in `~/src`
to build on — checked). Firebase-only, per the "new syncing apps are
Firebase-only" rule.
### must
- must not require the phone to relay anything — the whole point is
surviving the phone being off.
- optional: a physical snooze/dismiss button, since the phone's own dismiss
gate isn't reachable from the device.
### done
On a Tue/Wed/Thu, power the phone off right at the alarm and the ESP32
still rings, and keeps ringing, until the phone's session shows a start.
### verify
Bench test first (USB power, no case), then a real overnight run.
### read first
- `DOCS-phone-alarm.md` (the Firebase alarm schema to poll)
- `DOCS-morning-session-pc.md` (why this exists: the workday-stick project,
2026-09-23)
## Item 2 — wearable wake-defeat check
### what
A cheap fitness band whose own sleep log (stored on the band, synced later)
can show "asleep again after the alarm rang" even when the phone was fully
off and missed the whole thing.
### where
new app or an extension of `phone_app/`, once a device is picked.
### must
- must not: buy or integrate anything before checking Gadgetbridge's
current supported-device list — research first, kuhy owns no wearable and
none is integrated anywhere in `~/src` today (checked).
### done
Not yet scoped — picking a supported device is the blocking step.
### verify
Blank — desktop, once there's something to verify.

View File

@ -1,90 +0,0 @@
# Wake enforcement v2 — follow-ups for the next session
REMOVE ME AFTER FINISH
Handoff from the 2026-10-01 session that built v2. Read
`DOCS-wake-enforcement.md` (the contract + verification status) and
`AGENTS.md` first. Start **after the morning of 2026-10-02** so there is real
evidence to check.
## Prompt to paste into the new session
> Work through ~/src/wake-alarm/TODO-wake-enforcement-v2-followups.md top to
> bottom. Verify each item yourself on the real target (phone 23181JEGR08034,
> this PC) before calling it done, then delete the TODO file in the final
> commit.
## 1. Check the first real morning (2026-10-02, Fri, carrot day)
- `python -m wake_alarm._audit 2026-10-02` (and the 11:05 file in
`~/data/wake-alarm/incidents/`). Expected: phone `alarm_fired`
(`reason=daily`), `ring_started_published` with `source=alarm` (headless
engine, release build — never verified in release before) or
`ring_screen`, `stick_check` verdict for a non-stick day, no fallback ring
unless the phone really was silent.
- `adb logcat` for `WakeAlarmHeadless` / `WakeAlarmStick*` if anything is
missing. Firebase fields `ring_started`, `phone_events` must be non-empty
(`enforcement_view(fetch_firebase_logs())`).
- Any MISMATCH in the report is a bug to fix, not a note.
## 2. focus-owner reacting to a real lockdown (needs kuhy's OK + timing)
Never verified on the Pixel. Either wait for the first real Tue/Wed/Thu miss
and read focus-owner's Debug log (`LOCKDOWN_ACTIVATED` with the through-date,
then a `WORKDAY_LOCKDOWN` pass), or — with kuhy's explicit OK, at a time he
names — trigger one and lift it with the escape button (logs an escape).
## 3. Root ring vs a card held busy by PipeWire
Host test 2026-10-01: `wake-alarm-ring-alsa.service` got `Device or resource
busy` while the user's PipeWire held card `Generic_1` (ALC897). kuhy approved
killing the holder (`fuser -k -TERM /dev/snd/pcmC<N>D0p` after 3 busy opens,
in `scripts/pc/ring_alsa.sh`), but the auto-mode classifier refused the edit
twice. **kuhy must add a permission rule first**, or choose the non-killing
alternative: when the root unit cannot open the card, `_ring_player` falls
back to `PipeWireBackend` (PipeWire holds the card, so it can mix the ring
in). Add a vmbox case that holds `snd-dummy` open and proves the ring still
plays.
## 4. S5 (poweroff) RTC wake test on the host
Hibernate (S4) wake is verified and is the default mode. S5 is not. The
self-resume boot unit (`~/data/claude-scratch/wake-fw/s5_after_boot.sh`,
never installed) was refused by the classifier. Simplest: with kuhy present,
`sudo -n rtcwake -m no -s 240` + record the epoch, `systemctl poweroff`; kuhy
powers on if it does not wake; then compare `uptime -s` with the epoch. If S5
works, decide with kuhy whether to switch `pc_wake.json` to `poweroff` (covers
a typed `shutdown`, not just the power key).
## 5. utils dependency upgrade — prepared, uncommitted
In `~/src/utils` (do NOT stage the other session's `ts_config/README.md`,
`scripts/lib/alias-imports.mjs`, `test/alias-imports.test.js`): pylint 4.1.1
×3, typescript-eslint 8.71.0 (ts_config/ts_core/web_ui), @eslint-react
5.23.3, eslint-plugin-sonarjs 4.2.2, pnpm 12.8.1 (+ lockfiles,
`ts_config/pnpm-workspace.yaml`), allowlist: typescript-eslint entry removed,
typescript entry now `blocked_by: transitive:typescript-eslint@8.71.0`.
Lint + tests pass in all three. `finish_auto.sh` fails at its test step with
corepack `MODULE_NOT_FOUND .../pnpm/12.8.1/bin/pnpm.cjs` (pnpm 12.8 ships a
native binary; corepack 0.36 can't launch it). Global pnpm 12.8.1 is now
installed directly (`corepack disable pnpm`); find what in the gate still
calls corepack (read `~/data/claude-scratch/wake-v2/utils-commit.log`), fix,
commit, watch CI green.
## 6. Fleet repos still pinning typescript-eslint 8.70.0
The allowlist entry that excused them is gone, so their freshness gate turns
red on the next run: konbini-67, iron-and-anvil, awesome-mcp-explorer,
europe-county-map and any other repo pinning it (`grep -rl
'"typescript-eslint": "8.70.0"' ~/src --include=package.json`). Bump each to
8.71.0, lint + test, commit, CI green.
## 7. Small leftovers
- `~/src/todo`: `cupertino_icons` 1.0.9 → 2.0.0 — another session had
uncommitted work there; only bump once its tree is clean.
- Global `~/.claude/CLAUDE.md` sandbox-first note says the vmbox guest is
BIOS-only; vmbox has `--uefi` and `vm wake` (RTC-wake emulation) since
utils 694c013. Ask kuhy before editing that file.
- Clear the test data left in the `wake_alarm_api36` emulator (escape for
10-01, ringStarted, stick state) before it is ever signed into sync.

View File

@ -11,7 +11,7 @@
# What it does:
# 1. Installs wake_alarm + dependencies for /usr/bin/python
# 2. Installs the systemd user units and enables their timers (trigger,
# session, fallback, rtc, audit)
# session, strip, fallback, rtc, audit)
# 3. Installs the root pieces of the fallback alarm: the ring WAVs, the
# ALSA ring script + system unit, the sudoers lines that let the user
# start/stop that unit, and (pc_wake.json "hibernate" mode only) the
@ -47,6 +47,8 @@ readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
readonly SESSION_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-session.service"
readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer"
readonly STRIP_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-strip.service"
readonly STRIP_PATH_SRC="$SCRIPT_DIR/wake-alarm-strip.path"
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
# Enforcement v2 user units (service + timer each).
readonly V2_UNITS=(wake-alarm-fallback wake-alarm-rtc wake-alarm-audit)
@ -134,7 +136,8 @@ echo " wake_alarm and gatelock import under /usr/bin/python."
echo "[2/4] Installing systemd user units..."
mkdir -p "$SYSTEMD_USER_DIR"
cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" \
"$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" "$SYSTEMD_USER_DIR/"
"$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" \
"$STRIP_SERVICE_SRC" "$STRIP_PATH_SRC" "$SYSTEMD_USER_DIR/"
for unit in "${V2_UNITS[@]}"; do
cp "$SCRIPT_DIR/$unit.service" "$SCRIPT_DIR/$unit.timer" "$SYSTEMD_USER_DIR/"
done
@ -143,6 +146,8 @@ systemctl --user enable --now wake-alarm-trigger.timer
echo " Trigger timer enabled and started (ticks once a minute)."
systemctl --user enable --now wake-alarm-session.timer
echo " Session timer enabled (07:00-11:00, every 2 min)."
systemctl --user enable --now wake-alarm-strip.path
echo " Strip path enabled (corner reminder while the morning is in progress)."
for unit in "${V2_UNITS[@]}"; do
systemctl --user enable --now "$unit.timer"
done

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
import 'package:wake_alarm_sync/screens/home_presence_line.dart';
import 'package:wake_alarm_sync/screens/stakes_card.dart';
import 'package:wake_alarm_sync/screens/home_tiles.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The home screen's body: the armed time, the earlier-only control and the
@ -56,90 +55,88 @@ class HomeBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Alarm is armed for', style: TextStyle(fontSize: 16)),
HomePresenceLine(key: ValueKey<int>(syncEpoch)),
const SizedBox(height: 16),
Text(
time,
style: Theme.of(
context,
).textTheme.displayLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
isOverridden
? 'One-shot early start — back to $baseline '
'after it rings'
: 'Every day. It cannot be turned off.',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: busy ? null : onPickEarlier,
icon: const Icon(Icons.arrow_upward),
label: const Text('Wake me earlier tomorrow'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
// Fits one screen: stakes and history sit behind tiles. The scroll view
// is only the landscape safety net (~360px of body height); in portrait
// its extent is 0, which `home_fit_test.dart` asserts.
child: LayoutBuilder(
builder: (context, box) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: box.maxHeight - 48),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Alarm is armed for',
style: TextStyle(fontSize: 16),
),
),
),
const SizedBox(height: 24),
if (status.isNotEmpty)
Text(
status,
textAlign: TextAlign.center,
// Bounded: status can be a raw `'Error: $e'`, i.e. an
// arbitrarily long exception toString(). Unbounded, that
// alone added 100-150px and pushed this column past the
// viewport even in portrait. The scroll view above makes
// the overflow recoverable; this keeps it from happening.
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: status.contains('Error')
? Theme.of(context).colorScheme.error
: Theme.of(
context,
).extension<AppStatusColors>()!.success,
HomePresenceLine(key: ValueKey<int>(syncEpoch)),
const SizedBox(height: 16),
Text(
time,
style: theme.textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 24),
// A third distinct key in this Column, for the same reason as
// the history card's below.
StakesCard(
key: ValueKey<String>('stakes-$syncEpoch'),
busy: busy,
onEscaped: onEscaped,
),
const SizedBox(height: 24),
// Not `ValueKey<int>(syncEpoch)`: the presence line above
// already carries that key in this Column, and two children
// with one key mis-reconcile silently in release -- the old
// presence element was painted here.
HomeHistoryCard(key: ValueKey<String>('history-$syncEpoch')),
if (!configured) ...[
const SizedBox(height: 16),
Text(
'Open Settings (gear icon) to connect Firebase.',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(
context,
).extension<AppStatusColors>()!.warning,
const SizedBox(height: 8),
Text(
isOverridden
? 'One-shot early start — back to $baseline '
'after it rings'
: 'Every day. It cannot be turned off.',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13),
),
),
],
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: busy ? null : onPickEarlier,
icon: const Icon(Icons.arrow_upward),
label: const Text('Wake me earlier tomorrow'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(height: 24),
if (status.isNotEmpty) ...[
Text(
status,
textAlign: TextAlign.center,
// Bounded: status can be a raw `'Error: $e'`, i.e. an
// arbitrarily long exception toString().
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: status.contains('Error')
? theme.colorScheme.error
: theme.extension<AppStatusColors>()!.success,
),
),
const SizedBox(height: 24),
],
HomeTiles(
syncEpoch: syncEpoch,
busy: busy,
onEscaped: onEscaped,
),
if (!configured) ...[
const SizedBox(height: 16),
Text(
'Open Settings (gear icon) to connect Firebase.',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.extension<AppStatusColors>()!.warning,
),
),
],
],
),
),
),
),
),

View File

@ -15,17 +15,23 @@ const int kHistoryShown = 7;
/// first session has finished — an empty card would only say "not yet".
class HomeHistoryCard extends StatefulWidget {
/// Creates the card; [store] is injectable for tests.
const HomeHistoryCard({super.key, this.store});
const HomeHistoryCard({super.key, this.store, this.emptyText});
/// Session persistence; defaults to SharedPreferences.
final SessionStore? store;
/// Shown once loading found no finished morning, instead of nothing. The
/// home screen opens this card from a tile, so a blank sheet needs a
/// sentence.
final String? emptyText;
@override
State<HomeHistoryCard> createState() => _HomeHistoryCardState();
}
class _HomeHistoryCardState extends State<HomeHistoryCard> {
List<MorningSession> _sessions = const <MorningSession>[];
bool _loaded = false;
@override
void initState() {
@ -36,12 +42,18 @@ class _HomeHistoryCardState extends State<HomeHistoryCard> {
Future<void> _load() async {
final history = await (widget.store ?? SessionStore()).history();
if (!mounted) return;
setState(() => _sessions = history.take(kHistoryShown).toList());
setState(() {
_sessions = history.take(kHistoryShown).toList();
_loaded = true;
});
}
@override
Widget build(BuildContext context) {
if (_sessions.isEmpty) return const SizedBox.shrink();
if (_sessions.isEmpty) {
final empty = widget.emptyText;
return _loaded && empty != null ? Text(empty) : const SizedBox.shrink();
}
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
import 'package:wake_alarm_sync/screens/stakes_card.dart';
/// The two detail tiles under the alarm time: today's stakes and the last
/// week of mornings.
///
/// The home screen fits one phone screen with no scrolling, so these cards
/// moved behind a tap instead of stacking under the control. Each opens its
/// full card in a bottom sheet; nothing was dropped.
class HomeTiles extends StatelessWidget {
/// Creates the tiles; [syncEpoch] and [busy] reach the cards they open.
const HomeTiles({
required this.syncEpoch,
required this.busy,
this.onEscaped,
super.key,
});
/// Bumped after every sync; a sheet opened afterwards loads fresh data.
final int syncEpoch;
/// Disables the escape hatch while the screen is syncing.
final bool busy;
/// Called after the escape hatch was used, so the screen publishes it.
final VoidCallback? onEscaped;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: _Tile(
key: const Key('home-tile-stakes'),
icon: Icons.gavel_outlined,
label: "Today's stakes",
sheet: StakesCard(
key: ValueKey<String>('stakes-$syncEpoch'),
busy: busy,
onEscaped: onEscaped,
emptyText: 'No stakes to show: the native side has not answered.',
),
),
),
const SizedBox(width: 12),
Expanded(
child: _Tile(
key: const Key('home-tile-history'),
icon: Icons.history,
label: 'Last mornings',
sheet: HomeHistoryCard(
key: ValueKey<String>('history-$syncEpoch'),
emptyText: 'No finished morning yet.',
),
),
),
],
);
}
}
class _Tile extends StatelessWidget {
const _Tile({
required this.icon,
required this.label,
required this.sheet,
super.key,
});
final IconData icon;
final String label;
final Widget sheet;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: theme.colorScheme.surfaceContainerHigh,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: sheet,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 8),
Text(label, textAlign: TextAlign.center, maxLines: 1),
],
),
),
),
);
}
}

View File

@ -25,6 +25,7 @@ class StakesCard extends StatefulWidget {
this.nextAlarm,
this.onEscaped,
this.busy = false,
this.emptyText,
});
/// Native stick reader; defaults to the real channel.
@ -45,6 +46,10 @@ class StakesCard extends StatefulWidget {
/// Disables the escape while the screen is syncing.
final bool busy;
/// Shown once loading found no native stick, instead of nothing. The home
/// screen opens this card from a tile, so a blank sheet needs a sentence.
final String? emptyText;
@override
State<StakesCard> createState() => _StakesCardState();
}
@ -56,6 +61,7 @@ class _StakesCardState extends State<StakesCard> {
EnforcementState? _state;
AlarmTime? _next;
String _message = '';
bool _loaded = false;
DateTime _now() => (widget.now ?? DateTime.now)();
@ -78,6 +84,7 @@ class _StakesCardState extends State<StakesCard> {
setState(() {
_state = state;
_next = next;
_loaded = true;
});
}
@ -107,7 +114,10 @@ class _StakesCardState extends State<StakesCard> {
@override
Widget build(BuildContext context) {
final state = _state;
if (state == null) return const SizedBox.shrink();
final empty = widget.emptyText;
if (state == null) {
return _loaded && empty != null ? Text(empty) : const SizedBox.shrink();
}
final theme = Theme.of(context);
final warning = theme.extension<AppStatusColors>()?.warning;
final view = describeStakes(now: _now(), state: state, nextAlarm: _next);

View File

@ -1,11 +0,0 @@
/// Platform gate for the programmatic Google sign-in flow.
///
/// Conditional export because the answer differs per platform *and* because
/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws
/// `UnimplementedError` where no implementation is registered -- an `Error`,
/// not an `Exception`, so it escapes an ordinary catch and would take down
/// the settings screen's `build()`.
library;
export 'google_platform_io.dart'
if (dart.library.js_interop) 'google_platform_web.dart';

View File

@ -1,10 +0,0 @@
/// Whether the programmatic Google sign-in flow exists on this platform.
///
/// The io half: Android has the Credential Manager flow; Linux desktop does
/// not, and this app's desktop build is the web one anyway.
library;
import 'dart:io';
/// True only on Android, the one platform shipping the programmatic flow.
bool get googleSignInSupported => Platform.isAndroid;

View File

@ -1,8 +0,0 @@
/// Whether the programmatic Google sign-in flow exists on this platform.
///
/// The web half: Google Identity Services signs in only through its own
/// rendered button, so `authenticate()` throws `UnimplementedError` here.
library;
/// Always false: the web plugin has no programmatic sign-in.
bool get googleSignInSupported => false;

View File

@ -2,9 +2,9 @@
///
/// Why this is its own file: `crdt_sync` is deliberately pure Dart (only
/// `crypto` and `http`), because the same library runs on Linux desktop and
/// headless under systemd, where `google_sign_in` does not exist. So the
/// plugin lives here, in the app, and the library only ever receives a token
/// string through a closure.
/// headless under systemd, where Android's AccountManager does not exist. So
/// the plugin call lives here, in the app, and the library only ever receives
/// a token string through a closure.
///
/// What this buys: on a fresh install the sync account is reached by tapping
/// one account-picker row instead of typing a long password on a phone
@ -12,10 +12,7 @@
/// credential for desktop and the Python side.
library;
import 'dart:developer';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:wake_alarm_sync/services/google_platform.dart' as platform_gate;
import 'package:google_account_token/google_account_token.dart' as plugin;
/// The account the security rules pin.
///
@ -50,73 +47,26 @@ const kServerClientId =
/// Whether this platform can sign in programmatically.
///
/// False on web, where Google Identity Services only signs in through its own
/// rendered button: `authenticate()` throws `UnimplementedError` there. The
/// desktop build of this app *is* the web build, so without this check the
/// "Sign in with Google" button would crash the settings screen on desktop
/// rather than falling back; see `google_platform.dart` for why this is a
/// platform check rather than a question asked of the plugin.
/// True only on Android (the plugin's own gate): the desktop build of this
/// app *is* the web build, and neither has an AccountManager, so without this
/// check the "Sign in with Google" button would show a flow that can never
/// succeed instead of falling back to the password path.
///
/// Also false when no client id is compiled in: a button that cannot possibly
/// succeed is worse than no button, and without this check an unconfigured
/// build reports "cancelled" and sends you debugging the OAuth console for a
/// problem that is really a missing constant.
/// succeed is worse than no button.
bool get googleSignInSupported =>
platform_gate.googleSignInSupported && kServerClientId.isNotEmpty;
plugin.googleAccountTokenSupported && kServerClientId.isNotEmpty;
/// Returns a Google ID token for the signed-in account, or null.
///
/// Null rather than throwing when the user dismisses the picker: cancelling is
/// an ordinary outcome, and the caller falls back to the password path.
///
/// [signInFn] and [serverClientId] exist for tests, which cannot reach the
/// platform channel the plugin uses.
/// [signInFn] and [serverClientId] exist for tests that bypass the plugin.
Future<String?> googleIdToken({
Future<String?> Function()? signInFn,
String serverClientId = kServerClientId,
}) async {
if (signInFn != null) return await signInFn();
if (serverClientId.isEmpty) {
// A build without --dart-define=GOOGLE_SERVER_CLIENT_ID would otherwise
// fail inside the plugin with a far less obvious message.
log(
'Google sign-in unavailable: GOOGLE_SERVER_CLIENT_ID was not set at '
'build time; falling back to the password path',
level: 900,
);
return null;
}
// The plugin reaches the OS account picker through a platform channel,
// which `flutter test` has no binding for -- the same reason main.dart and
// firebase_backend.dart's keystore adapters are excluded. Everything above
// this point is pure Dart and is covered.
// coverage:ignore-start
try {
await GoogleSignIn.instance.initialize(serverClientId: serverClientId);
if (!GoogleSignIn.instance.supportsAuthenticate()) {
// Web/desktop: sign-in must go through the rendered GIS button, which
// this app does not host yet. Fall back rather than throwing.
log(
'Google sign-in is not available on this platform; falling back to '
'the password path',
level: 900,
);
return null;
}
final account = await GoogleSignIn.instance.authenticate();
return account.authentication.idToken;
} on GoogleSignInException catch (error, stackTrace) {
// Includes the user simply dismissing the picker, which is not an error
// worth surfacing -- but log it, because a *configuration* failure
// (unregistered SHA-1, wrong client id) arrives through the same path and
// is otherwise indistinguishable from "the user changed their mind".
log(
'Google sign-in did not complete',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
// coverage:ignore-end
return await plugin.googleIdToken(serverClientId: serverClientId);
}

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
android_intent_plus:
dependency: "direct main"
description:
@ -101,11 +101,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.11.3"
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.11.3"
version: "0.12.0"
cross_file:
dependency: transitive
description:
@ -134,11 +134,11 @@ packages:
dependency: "direct main"
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.0"
resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d
ref: "data_transfer_flutter-v0.2.1"
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.0"
version: "0.2.1"
dbus:
dependency: transitive
description:
@ -183,18 +183,18 @@ packages:
dependency: transitive
description:
name: file_picker
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
url: "https://pub.dev"
source: hosted
version: "13.3.1"
version: "13.3.2"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.2"
file_picker_linux:
dependency: transitive
description:
@ -371,54 +371,15 @@ packages:
url: "https://github.com/kuhyx/utils"
source: git
version: "0.3.2"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
google_account_token:
dependency: "direct main"
description:
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
url: "https://pub.dev"
source: hosted
version: "7.2.16"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
url: "https://pub.dev"
source: hosted
version: "1.1.3"
path: google_account_token
ref: "google_account_token-v0.1.0"
resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
hooks:
dependency: transitive
description:
@ -772,11 +733,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.5.2"
resolved-ref: "648bde44b1756485d95ca3c3a8714cd2015d5be4"
ref: "sync_settings_ui-v0.6.1"
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
url: "https://github.com/kuhyx/utils"
source: git
version: "0.5.2"
version: "0.6.1"
term_glyph:
dependency: transitive
description:

View File

@ -12,7 +12,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.11.3
ref: crdt_sync_dart-v0.12.0
path: crdt_sync_dart
cupertino_icons: 2.0.0
@ -20,7 +20,7 @@ dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.0
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
flutter:
sdk: flutter
@ -33,7 +33,11 @@ dependencies:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.2
path: github_device_auth
google_sign_in: 7.2.0
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
http: 1.6.0
path: 1.9.1
path_provider: 2.1.6
@ -45,7 +49,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.5.2
ref: sync_settings_ui-v0.6.1
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -1,14 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
void main() {
test('reports whether the programmatic Google flow exists here', () {
// Android is the only platform shipping it. Asked as a platform question
// rather than of the plugin, because GoogleSignIn.supportsAuthenticate()
// itself throws UnimplementedError where no implementation is registered
// -- an Error, not an Exception, so it would escape an ordinary catch and
// take down the settings screen's build(). The test host is Linux, so
// this is false here and the password path stays visible.
expect(googleSignInSupported, isFalse);
});
}

View File

@ -1,15 +1,24 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
const _channel = MethodChannel('dev.kuhy/google_account_token');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_channel, null);
});
group('googleIdToken', () {
test('returns the token the sign-in function yields', () async {
// The injected path: proves the closure is what feeds crdt_sync, and
// that no platform channel is touched to get there.
expect(
await googleIdToken(signInFn: () async => 'a-token'),
'a-token',
);
expect(await googleIdToken(signInFn: () async => 'a-token'), 'a-token');
});
test('passes a null cancellation straight through', () async {
@ -19,12 +28,24 @@ void main() {
});
test('reports "not configured" when the client id is missing', () async {
// Passed explicitly rather than relying on the const: an app whose
// client id is filled in would otherwise reach the real plugin here.
// The behaviour under test is the empty-id fallback itself.
// An empty client id must fall back rather than reach the OS chooser.
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(await googleIdToken(serverClientId: ''), isNull);
});
test('asks the account-token plugin for the Web client id', () async {
// The real path: the plugin's channel gets the pinned Web client id.
debugDefaultTargetPlatformOverride = TargetPlatform.android;
Object? sent;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_channel, (call) async {
sent = call.arguments;
return 'plugin-token';
});
expect(await googleIdToken(), 'plugin-token');
expect(sent, {'serverClientId': kServerClientId});
});
test('pins the uid the security rules expect', () {
// If this ever drifts from database.rules.json, a Google sign-in would
// be accepted here and then denied every read and write.

View File

@ -0,0 +1,19 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
void main() {
tearDown(() => debugDefaultTargetPlatformOverride = null);
test('the one-tap button is hidden off Android', () {
// flutter_test defaults to Android, so pin Linux (the desktop build): no
// AccountManager, so the password path stays the only option.
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
expect(googleSignInSupported, isFalse);
});
test('the one-tap button shows on Android', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(googleSignInSupported, isTrue);
});
}

View File

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/home_body.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
import 'package:wake_alarm_sync/screens/stakes_card.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/session_store.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'fake_enforcement_channel.dart';
/// The home screen on one phone screen, at Pixel 6a geometry: 1080x2400 px at
/// 2.625, status bar 132 px, nav bar 63 px (from `dumpsys window`).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeEnforcementChannel native;
var picked = 0;
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
native = FakeEnforcementChannel()..install();
picked = 0;
});
Future<void> pumpHome(WidgetTester tester, {required String status}) async {
tester.view
..physicalSize = const Size(1080, 2400)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 132, bottom: 63);
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
appBar: AppBar(
title: const Text('Wake Alarm Sync'),
actions: const [
Icon(Icons.event_available),
Icon(Icons.sick_outlined),
Icon(Icons.settings),
],
),
body: HomeBody(
time: '07:00',
baseline: '07:30',
isOverridden: true,
busy: false,
status: status,
configured: false,
onPickEarlier: () => picked++,
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
Future<void> seedHistory() async {
final store = SessionStore();
for (var day = 1; day <= 9; day++) {
await store.archive(
SessionRules.start(
now: DateTime(2026, 9, day, 8),
tags: const SessionTagSettings(),
nfcAvailable: false,
)..weightKg = 70.0 + day,
);
}
}
void expectNoVerticalScroll(WidgetTester tester) {
for (final element in find.byType(Scrollable).evaluate()) {
final state = (element as StatefulElement).state as ScrollableState;
if (state.axisDirection == AxisDirection.down ||
state.axisDirection == AxisDirection.up) {
expect(state.position.maxScrollExtent, 0, reason: 'vertical scroll');
}
}
}
testWidgets('worst case fits one screen with the control reachable', (
tester,
) async {
native.state = nativeState(streak: 1, lockedThrough: '2026-10-02');
await seedHistory();
await pumpHome(tester, status: 'Error: ${'x' * 400}');
expect(tester.takeException(), isNull);
expectNoVerticalScroll(tester);
const bottomChrome = 2400 / 2.625 - 63 / 2.625;
final button = find.text('Wake me earlier tomorrow');
expect(tester.getRect(button).bottom, lessThan(bottomChrome));
await tester.tap(button);
expect(picked, 1);
});
testWidgets('stakes and history sit behind tiles, not on the page', (
tester,
) async {
native.state = nativeState(streak: 1, lockedThrough: '2026-10-02');
await seedHistory();
await pumpHome(tester, status: '');
expect(find.byType(StakesCard), findsNothing);
expect(find.byType(HomeHistoryCard), findsNothing);
await tester.tap(find.byKey(const Key('home-tile-stakes')));
await tester.pumpAndSettle();
expect(find.byType(StakesCard), findsOneWidget);
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('home-tile-history')));
await tester.pumpAndSettle();
expect(find.byType(HomeHistoryCard), findsOneWidget);
expect(find.textContaining('09-09'), findsOneWidget);
});
}

View File

@ -69,6 +69,15 @@ void main() {
expect(find.text('Last mornings'), findsNothing);
});
testWidgets('says so when no morning has finished', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: HomeHistoryCard(emptyText: 'None yet.')),
);
expect(find.text('None yet.'), findsNothing);
await tester.pump();
expect(find.text('None yet.'), findsOneWidget);
});
testWidgets('lists the last seven with weight deltas', (tester) async {
final store = SessionStore();
for (var day = 1; day <= 9; day++) {

View File

@ -0,0 +1,24 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
/// The PC reads leg names (the keys of the published `passed` map) and labels
/// (its `next_step`) from the same literal: the Python half asserts it in
/// `wake_alarm/tests/test_session_legs.py`. Neither side's own round-trip can
/// catch a rename on the other.
void main() {
test('SessionLeg names and labels equal the shared fixture', () {
final raw = File(
'../wake_alarm/tests/fixtures/session_legs.json',
).readAsStringSync();
final legs = (jsonDecode(raw) as Map<String, Object?>)['legs']! as List;
expect(
<(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)],
<(String, String)>[
for (final l in legs) (l['name'] as String, l['label'] as String),
],
);
});
}

View File

@ -179,7 +179,7 @@ void main() {
expect(await EscapeLog.load(), isEmpty);
});
testWidgets('the home body carries the card', (tester) async {
testWidgets('the home body opens the card from its tile', (tester) async {
native.state = nativeState();
await tester.pumpWidget(
MaterialApp(
@ -198,6 +198,21 @@ void main() {
),
);
await tester.pump();
expect(find.byType(StakesCard), findsNothing);
await tester.tap(find.byKey(const Key('home-tile-stakes')));
await tester.pumpAndSettle();
expect(find.byType(StakesCard), findsOneWidget);
});
testWidgets('says so when the native side answers nothing', (tester) async {
native.state = null;
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StakesCard(emptyText: 'No stakes.')),
),
);
expect(find.text('No stakes.'), findsNothing);
await tester.pump();
expect(find.text('No stakes.'), findsOneWidget);
});
}

View File

@ -1,202 +0,0 @@
> **This file is a ready-to-use prompt.** Open this repo and say
> "do dopamine-ux-wake-alarm". It is self-contained -- it needs no context from
> any other session.
>
> Canonical copy: `~/src/utils/dopamine-ux/09-wake-alarm.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
> as of that date; **anchor on the symbol names, not the line numbers.**
## ⛔ WHEN YOU FINISH THIS PROMPT
Do **both**, in the same session that completes the work:
1. **Delete this file** and commit that deletion together with the
implementation:
```bash
git rm prompts/dopamine-ux-wake-alarm.md
```
Do not leave a finished prompt lying in the repo -- a stale prompt is
indistinguishable from a pending one, and the next session will re-run it.
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/src/utils
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
# | 09-wake-alarm.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
git add dopamine-ux/00-INDEX.md
git commit -m "dopamine-ux: mark 09-wake-alarm.md done"
git push
```
If you finish only part of the prompt, do **neither** -- instead edit this file
to strike out what is done and state what remains, so the next session starts
from the true state. Partial completion must never be recorded as DONE.
---
Add motion and haptics to the wake-alarm phone app
## findings (2, ranked by value over effort)
1. **Setting an alarm gives no confirmation or haptic** — and since the real
alarm lives in another app entirely, confirming the handoff is the only
reassurance this app can offer.
2. **Zero motion, and the thinnest local theme in the fleet** — it lacks
`AppSpacing`/`AppRadius`/`AppTextSize`, so widgets copied from sibling apps
will not compile here.
## what
`~/src/wake-alarm/phone_app` is the smallest surface in this project: 12 Dart files,
a sync companion that sets a time, syncs it, and hands off to the system clock
app. It has **zero motion, zero haptics, zero audio, zero progress feedback** —
verified 2026-08-16. Its settings screen is a 56-line `StatelessWidget` with two
`ListTile`s and no state at all.
Bring it up to the fleet's baseline: a confirmation the alarm was actually set,
motion tokens on its transitions, and a haptic on the one action that matters.
**This app is deliberately last and deliberately small.** It also carries the
project's sharpest hazard — see `must not` about alarm audio.
## where
Repo: `~/src/wake-alarm`. Flutter app: `~/src/wake-alarm/phone_app` (**underscore**, not
`phone-app`). The repo root is a Python package; the Flutter app is only the
`phone_app/` subdirectory.
Primary:
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` ~:86-96 builds an
`AndroidIntent(action: 'android.intent.action.SET_ALARM', arguments: {HOUR,
MINUTES, SKIP_UI: false, MESSAGE: 'Wake Alarm'})` via `android_intent_plus`.
The `CircularProgressIndicator` is at ~:189. The only `Duration` in the whole
app is `.timeout(const Duration(seconds: 3))` ~:95 — **an intent timeout, not
animation. Do not tokenise it.**
- `phone_app/lib/screens/settings_screen.dart` — 56 lines, `StatelessWidget`,
`ListView` ~:21-53, `MaterialPageRoute` ~:30, :49.
- `phone_app/lib/services/sync_settings.dart` — holds **only** the GitHub token
in the keystore (`FlutterSecureStorage`, `_secureTokenKey = 'github_token'`
~:31, `load()` ~:36, `save()` ~:61). There is no general app-preferences class.
- `phone_app/lib/ui/theme.dart` — local hand-copy, **no `design_system`
dependency**. `buildAppTheme()` ~:12, `AppStatusColors` ~:54. ⚠️ Unlike the
other apps it does **not** declare `AppSpacing`/`AppRadius`/`AppTextSize`, so a
widget copied from diet-guard or workout_app **will not compile here**. Wired at
`phone_app/lib/main.dart` ~:18.
## must
**Step 1 — motion + haptics (no new dependency).**
- Add `HapticFeedback.mediumImpact()` when the alarm is successfully set — the
single most important action in the app. Fire it on the tap, not after the
intent round-trips. `HapticFeedback` is in `package:flutter/services.dart`; add
`<uses-permission android:name="android.permission.VIBRATE"/>` to
`phone_app/android/app/src/main/AndroidManifest.xml` if absent.
- Give setting the alarm a **clear confirmation**: which time was set, and that
the handoff to the system clock succeeded. Today the user gets a `_status`
string and a spinner. Since the actual alarm lives in another app, confirming
the handoff is the only reassurance this app can offer — make it unambiguous.
- Transcribe the motion tokens from prompt 01 into `phone_app/lib/ui/theme.dart`,
matching the existing hand-copy convention. Add `AppSpacing`/`AppRadius`/
`AppTextSize` **only if** you need them; otherwise leave the file's shape alone.
- Apply motion tokens to the two `MaterialPageRoute` transitions.
- Honour `MediaQuery.of(context).disableAnimations`.
**Step 2 — sound (optional here; consider skipping).**
- A UI confirmation cue would need an audio dependency, an `assets:` block, a
settings toggle, **and** a preferences class that does not exist yet (see
below). For an app whose entire job is to hand off to another app, that is a
poor value ratio.
- **Recommendation: skip sound in this app** and say so explicitly in the session
summary. If you do add it, it needs the full opt-out toggle like everywhere
else — and the toggle work below.
- To add any toggle here you must do three things the other apps do not require:
(a) convert `SettingsScreen` from `StatelessWidget` to `StatefulWidget`,
(b) create a preferences class — there is no `AppSettings` equivalent; model it
on `~/src/todo/lib/data/app_settings.dart`, and note `shared_preferences: ^2.3.0`
is **already a dependency**, (c) add the `SwitchListTile` to the `ListView`.
**Both steps:**
- must not: **touch anything under `~/src/wake-alarm/wake_alarm/`.** That is the
desktop Python daemon that produces the real alarm noise — sine-tone WAVs via
`paplay` → `aplay` → `speaker-test` (`_audio.py`, `_play_on_all_sinks` ~:218,
pcspkr evdev fallback `_beep_pcspkr` ~:81), escalating 440Hz → 1000Hz → loud in
the loop at `_alarm.py` ~:393-407, with sink volume save/restore in `_sinks.py`.
**Never route a UI-sound opt-out through any of it.** A toggle that silences a
wake-up alarm is a bug that makes someone late for work.
- The two systems never share a process, package, or file — `phone_app` has no
audio engine at all — so a UI sound layer here **structurally cannot** touch
alarm playback. Keep it that way; do not build a bridge between them.
- must not: change the alarm-setting intent, its arguments, or the 3s timeout.
Feedback wraps that call; it does not modify it.
- must not: add a streak, a counter, or any reward for setting an alarm. Waking
up is not a metric this app owns, and rewarding "alarms set" rewards the wrong
act entirely.
- must not: copy a widget from another app without checking it only uses symbols
this app's thinner theme actually declares.
## done
1. Setting an alarm produces a haptic and an unambiguous confirmation naming the
time that was set.
2. Page transitions use the shared motion tokens; no inline animation `Duration`
remains (the 3s intent timeout stays).
3. `cd ~/src/wake-alarm/phone_app && flutter analyze` is clean.
4. `cd ~/src/wake-alarm/phone_app && flutter test` passes.
5. With OS "remove animations" enabled, the app works and durations are zero.
6. `git diff` shows **no changes under `~/src/wake-alarm/wake_alarm/`**.
7. The summary states whether sound was added or deliberately skipped.
## verify
**On the phone.**
```
adb devices # confirm 23181JEGR08034
cd ~/src/wake-alarm/phone_app
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
Never `flutter install`, never `adb uninstall`, never `pm clear` — the app stores
a GitHub token in the keystore and wiping it means re-authenticating.
Then set a real alarm on the device: confirm the haptic fired (state it as a
physical observation), the confirmation named the right time, and **the system
Clock app actually received the alarm**. The last one is the real test — this
app's job is the handoff, so verify the alarm exists in the Clock app, not just
that this screen said so.
Do **not** test by letting an alarm actually fire unless kuhy asks — that
involves the desktop daemon and real noise.
## read first
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` in full,
including the timeout and the `_status` string handling.
- `phone_app/lib/ui/theme.dart` — note what it does **not** declare before
writing any widget.
- `~/src/todo/lib/data/app_settings.dart` — the preferences-class pattern, only if
you decide to do step 2.
- `~/src/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
**Prompt 01 must have run first.**
## context you would otherwise rediscover
- **The Flutter app produces no audio whatsoever.** No audio package, no
`assets:` block; `assets/` contains only launcher icons. It fires an Android
`SET_ALARM` intent and the **system Clock app** rings. The desktop Python
daemon is an entirely separate program on a different machine.
- The repo root (`~/src/wake-alarm/wake_alarm/`, `wake_alarm.egg-info`) is the Python
package. Only `phone_app/` is Flutter.
- This app has **no `design_system` dependency** and the thinnest local theme in
the fleet. Adding the dependency is out of scope here (see prompt 07 for what
that migration looks like when done properly).
- No streaks, no charts, no progress, no completion counts exist — and none
should be added. This is a two-button utility.
REMOVE ME AFTER FINISH

View File

@ -8,7 +8,7 @@ version = "1.0.0"
description = "Trigger that starts the morning routine at the phone's alarm time"
requires-python = ">=3.14"
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
# Firebase transport for the alarm read. _sync.py imports it lazily and
# falls back to its stdlib GitHub path if the import fails, so a broken
# install degrades the read instead of killing the per-minute trigger.
@ -16,7 +16,7 @@ dependencies = [
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
# a guard: missing means "not sick", never a crashed tick.
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
]
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python

View File

@ -4,10 +4,10 @@
# it is missing — but it is required here for mypy/pylint/tests.
bandit==1.9.4
codespell==2.4.3
coverage==7.17.0
coverage==7.17.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
@ -16,5 +16,5 @@ pytest-cov==7.1.0
pytest-randomly==5.0.0
pytest-sugar==1.1.1
pytest-xdist==3.8.0
ruff==0.17.3
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
ruff==0.17.4
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays

12
scripts/todo_hooks.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Shim: forward to the shared todo git hooks in ~/src/utils (silent if absent).
set -euo pipefail
readonly SHARED="${UTILS_ROOT:-$HOME/src/utils}/scripts/todo_hooks.sh"
main() {
[[ -f "$SHARED" ]] || exit 0
exec bash "$SHARED" "$@"
}
main "$@"

View File

@ -86,10 +86,14 @@ remove_trigger() {
echo " Disabling the trigger timer"
systemctl --user disable --now wake-alarm-trigger.timer >/dev/null 2>&1 || true
systemctl --user disable --now wake-alarm-session.timer >/dev/null 2>&1 || true
systemctl --user disable --now wake-alarm-strip.path >/dev/null 2>&1 || true
systemctl --user stop wake-alarm-strip.service >/dev/null 2>&1 || true
rm -f "$SYSTEMD_USER_DIR/wake-alarm-trigger.service" \
"$SYSTEMD_USER_DIR/wake-alarm-trigger.timer" \
"$SYSTEMD_USER_DIR/wake-alarm-session.service" \
"$SYSTEMD_USER_DIR/wake-alarm-session.timer"
"$SYSTEMD_USER_DIR/wake-alarm-session.timer" \
"$SYSTEMD_USER_DIR/wake-alarm-strip.service" \
"$SYSTEMD_USER_DIR/wake-alarm-strip.path"
}
remove_enforcement() {

View File

@ -11,8 +11,9 @@ OnCalendar=*-*-* 05..06:00/10:00
OnCalendar=*-*-* 07..10:00/2:00
OnCalendar=*-*-* 11:00:00
AccuracySec=1s
# A PC booted mid-morning refreshes at once; the consumers wait up to 30 s
# for exactly this catch-up before treating the file as stale.
# A PC booted mid-morning refreshes at once, retrying in-process every 5 s
# for 150 s (no DNS yet at boot); the consumers wait up to 180 s for exactly
# this catch-up before treating the file as stale.
Persistent=true
[Install]

14
wake-alarm-strip.path Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=Show the morning-session strip whenever the signed session file is rewritten
Documentation=https://github.com/kuhyx/wake-alarm
[Path]
# wake-alarm-session.timer rewrites this every 2 min through the morning (and
# the strip itself asks for a refresh every 30 s while it is up). Starting a
# service that is already running is a no-op, so the rewrites cost nothing;
# once the session is over the strip exits 0 at once.
PathChanged=%h/.local/state/wake_alarm/morning_session.json
Unit=wake-alarm-strip.service
[Install]
WantedBy=paths.target

14
wake-alarm-strip.service Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=Wake Alarm morning-session strip (top-right corner reminder)
Documentation=https://github.com/kuhyx/wake-alarm
[Service]
Type=simple
# Same display handling as leetcode-guard.service, the other Tk user unit:
# the session's own X server, no env fallbacks invented here.
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority
# Exits 0 by itself when the morning session is not in progress (the normal
# case for most path triggers) and again the moment it ends. Exit 1 means no
# display or no live output; the next file rewrite tries again.
ExecStart=/usr/bin/python -m wake_alarm._session_strip

View File

@ -156,6 +156,13 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0)
# PC). A phone switched off at the alarm locks this long after it.
SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
# A PC booted mid-morning often has no DNS for its first seconds (2026-11-07:
# one try at 07:48:43, exit 1, next timer tick at 07:50). ``--refresh`` keeps
# trying in-process this long, every REFRESH_POLL_SECONDS, before giving up.
# Consumers (gatelock.morning_session) wait up to 180 s, so this stays under.
REFRESH_RETRY_SECONDS: int = 150
REFRESH_POLL_SECONDS: int = 5
# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
# themselves are freedays.WORKDAYS, the one set every gate app reads.

View File

@ -31,10 +31,17 @@ from datetime import datetime
import json
import logging
import sys
import time
from typing import TYPE_CHECKING
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE
from wake_alarm._constants import (
FALLBACK_ALARM_HOUR,
MORNING_SESSION_FILE,
REFRESH_POLL_SECONDS,
REFRESH_RETRY_SECONDS,
)
from wake_alarm._enforcement_payload import enforcement_view
from wake_alarm._escapes import note_escapes
from wake_alarm._logging_setup import configure_logging
@ -45,6 +52,9 @@ from wake_alarm._sync_transport import fetch_firebase_logs
from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day
from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
@ -81,18 +91,49 @@ def read_entry() -> dict[str, object] | None:
return entry
def refresh(now: datetime) -> dict[str, object] | None:
def _fetch_with_retry(
sleep: Callable[[float], None], clock: Callable[[], float]
) -> list[tuple[str, str]] | None:
"""``fetch_firebase_logs`` retried every poll interval within the budget.
A PC booted mid-morning usually has no DNS for its first seconds, and the
next timer tick is minutes away while consumers are already waiting on the
file. None only once ``REFRESH_RETRY_SECONDS`` have passed.
"""
started = clock()
while True:
published = fetch_firebase_logs()
if published is not None:
return published
waited = clock() - started
if waited + REFRESH_POLL_SECONDS > REFRESH_RETRY_SECONDS:
_logger.warning(
"Morning session not refreshed: backend unreadable for %d s "
"(retried every %d s)",
REFRESH_RETRY_SECONDS,
REFRESH_POLL_SECONDS,
)
return None
sleep(REFRESH_POLL_SECONDS)
def refresh(
now: datetime,
*,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> dict[str, object] | None:
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
An unreadable backend leaves the file alone: whatever it says is still
the newest thing known, and a consumer judges freshness by its date. A
penalty issued on a day since marked sick is retracted first, from the
local pool alone, so it does not wait on Firebase.
An unreadable backend is retried for ``REFRESH_RETRY_SECONDS``, then
leaves the file alone: whatever it says is still the newest thing known,
and a consumer judges freshness by its date. A penalty issued on a day
since marked sick is retracted first, once, from the local pool alone, so
it does not wait on Firebase. ``sleep`` and ``clock`` are seams for tests.
"""
_cancel_for_sick_day()
published = fetch_firebase_logs()
published = _fetch_with_retry(sleep, clock)
if published is None:
_logger.info("Morning session not refreshed: backend unreadable")
return None
today = now.date().isoformat()
record = newest_session(published)

View File

@ -0,0 +1,30 @@
"""The morning session's legs, in order, as the phone names and labels them.
Mirrors ``SessionLeg`` and ``SessionLegSlot.label`` in
``phone_app/lib/services/morning_session.dart``. A leg's *name* is the key of
the phone's ``passed`` map; its *label* is what the PC's ``next_step`` shows.
``tests/fixtures/session_legs.json`` is asserted by a Python and a Dart test,
so a rename on one side fails the other's build.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection
LEGS: tuple[tuple[str, str], ...] = (
("bathroomIn", "Tap the bathroom tag"),
("showerDone", "Shower, then tap the bathroom tag again"),
("dressed", "Get dressed — tap the wardrobe tag"),
("atDesk", "Sit down — tap the desk tag"),
)
def next_step(passed: Collection[str]) -> str | None:
"""Label of the first leg (in order) not in ``passed``; None when all are."""
for name, label in LEGS:
if name not in passed:
return label
return None

View File

@ -52,6 +52,8 @@ class SessionRecord:
re_rings: int
device_id: str
published_at: float | None
# Names (``_session_legs.LEGS``) of the legs the phone has passed.
passed_legs: tuple[str, ...] = ()
@property
def finished(self) -> bool:
@ -73,6 +75,19 @@ def _float_or_none(value: object) -> float | None:
return float(value)
def _passed_legs(value: object) -> tuple[str, ...]:
"""Leg names in the phone's ``passed`` map that carry a pass timestamp."""
if not isinstance(value, dict):
return ()
return tuple(
name
for name, entry in value.items()
if isinstance(name, str)
and isinstance(entry, dict)
and isinstance(entry.get("at"), str)
)
def record_from_json(
value: object,
*,
@ -106,6 +121,7 @@ def record_from_json(
re_rings=_int_or_none(value.get("re_rings")) or 0,
device_id=device_id,
published_at=published_at,
passed_legs=_passed_legs(value.get("passed")),
)

View File

@ -25,6 +25,7 @@ from wake_alarm._constants import (
SESSION_PROVISIONAL_CAP,
SESSION_PROVISIONAL_GRACE,
)
from wake_alarm._session_legs import next_step
if TYPE_CHECKING:
from wake_alarm._session_payload import SessionRecord
@ -113,6 +114,7 @@ def to_entry(
"device_id": "",
"published_at": None,
"refreshed_at": refreshed_at,
"next_step": None,
}
if alarm is not None:
entry["exempt_until"] = (_at(today, alarm) + SESSION_NONE_GRACE).isoformat()
@ -128,6 +130,10 @@ def to_entry(
"device_id": record.device_id,
"published_at": record.published_at,
"refreshed_at": refreshed_at,
# Signed with the rest: the HMAC covers every key of the entry.
"next_step": (
next_step(record.passed_legs) if record.outcome == "in_progress" else None
),
}
until = exempt_until(record)
if until is not None:

View File

@ -0,0 +1,247 @@
"""A corner strip while the morning session is started but not finished.
wake-alarm's signed ``morning_session.json`` makes the lockers stand down
while the morning is live. The screen simply clears, though, and kuhy still
owes the rest of the routine: the locks return at ``exempt_until`` out of
nowhere. This strip is the reminder: a small always-on-top box in the
top-right corner of the primary output (``gatelock.corner_strip``), saying the
session is not finished, what the phone's next step is, and when locks return.
python -m wake_alarm._session_strip
``wake-alarm-strip.path`` starts it whenever the file is rewritten (every two
minutes through the morning); starting a running service is a no-op. It shows
only while the entry is *signed, dated today, in_progress, and now <
exempt_until*, re-reading the file every second, and exits 0 the moment that
stops being true. While visible it also asks systemd every 30 s to run
``wake-alarm-session.service`` so ``next_step`` follows the phone instead of
the two-minute timer.
Fail modes, out loud: nothing to show is exit 0 (normal, logged at info); no
display or no live output is a warning and exit 1, so the unit shows failed;
a missing, unreadable or tampered file shows nothing (the lockers do not
honour it either) and warns once per distinct problem, not once a second.
"""
from __future__ import annotations
from datetime import datetime
import json
import logging
import subprocess
import sys
import time
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import LockConfig, enumerate_outputs
from gatelock.corner_strip import StripContent, build_corner_strip, corner_rect
from gatelock.log_integrity import verify_entry_hmac
from wake_alarm._constants import MORNING_SESSION_FILE
from wake_alarm._logging_setup import configure_logging
if TYPE_CHECKING:
from subprocess import Popen
_logger = logging.getLogger(__name__)
TITLE = "Morning session not finished"
REFRESH_EVERY_SECONDS = 30.0
REFRESH_COMMAND = [
"systemctl",
"--user",
"start",
"--no-block",
"wake-alarm-session.service",
]
class _Once:
"""Warns about a problem once, until a clean read clears it.
The strip reads the file every second; a file that stays broken for an
hour must be one journal line, not 3600.
"""
def __init__(self) -> None:
self.last: str | None = None
def warn(self, message: str) -> None:
"""Log ``message`` at warning level unless it was just logged."""
if message != self.last:
_logger.warning(message)
self.last = message
def clear(self) -> None:
"""A clean read happened: the next problem is news again."""
self.last = None
_once = _Once()
def _local_now() -> datetime:
"""The current time as an aware local datetime (a test seam)."""
return datetime.now().astimezone()
def _monotonic() -> float:
"""Seconds on the monotonic clock (a test seam)."""
return time.monotonic()
def _popen(cmd: list[str]) -> Popen[bytes]:
"""Start ``cmd`` without waiting for it (a test seam)."""
return subprocess.Popen(cmd)
def _make_root() -> tk.Tk:
"""A withdrawn Tk root; only the strip's Toplevel is ever visible."""
root = tk.Tk()
root.withdraw()
return root
def read_entry() -> dict[str, object] | None:
"""The file's parsed JSON object, or None. Verification is the caller's."""
try:
text = MORNING_SESSION_FILE.read_text()
except FileNotFoundError:
return None
except OSError as exc:
_once.warn(f"Cannot read {MORNING_SESSION_FILE}: {exc}")
return None
try:
entry = json.loads(text)
except json.JSONDecodeError as exc:
_once.warn(f"Cannot parse {MORNING_SESSION_FILE}: {exc}")
return None
if not isinstance(entry, dict):
_once.warn(f"{MORNING_SESSION_FILE} is not a JSON object")
return None
_once.clear()
return entry
def _why_not_today(entry: dict[str, object], now: datetime) -> str | None:
"""Why ``entry`` is not a verified, live, in-progress morning; None if it is."""
if not verify_entry_hmac(entry):
return "morning session entry is unsigned or tampered"
if entry.get("date") != now.date().isoformat():
return f"morning session is dated {entry.get('date')}, not today"
if entry.get("outcome") != "in_progress":
return f"morning session outcome is {entry.get('outcome')}"
return None
def _exempt_until(entry: dict[str, object]) -> datetime | str:
"""The entry's aware ``exempt_until``, or the sentence saying why not."""
until = entry.get("exempt_until")
if not isinstance(until, str):
return "morning session has no exempt_until"
try:
parsed = datetime.fromisoformat(until)
except ValueError:
return f"morning session exempt_until is unreadable: {until!r}"
if parsed.tzinfo is None:
# Guessing its zone would be inventing a carrot the signer never gave.
return "morning session exempt_until has no timezone"
return parsed
def _verdict(entry: dict[str, object] | None, now: datetime) -> StripContent | str:
"""The strip's content, or the sentence saying why there is none."""
if entry is None:
return "no morning session file"
reason = _why_not_today(entry, now)
if reason is not None:
return reason
exempt_until = _exempt_until(entry)
if isinstance(exempt_until, str):
return exempt_until
if now >= exempt_until:
return f"the exemption ended at {exempt_until.isoformat()}"
lines: list[str] = []
step = entry.get("next_step")
if isinstance(step, str) and step:
lines.append(f"Next: {step}")
lines.append(f"Locks return at {exempt_until.astimezone(now.tzinfo):%H:%M}")
return StripContent(TITLE, tuple(lines))
def strip_content(
entry: dict[str, object] | None, now: datetime
) -> StripContent | None:
"""What the strip says for ``entry`` at local ``now``; None shows nothing.
Something is shown only for a verified entry dated today, still
``in_progress``, whose ``exempt_until`` has not passed.
"""
verdict = _verdict(entry, now)
return verdict if isinstance(verdict, StripContent) else None
class Refresher:
"""Starts wake-alarm-session.service every 30 s, never two at once."""
def __init__(self, *, start: float) -> None:
self._due = start + REFRESH_EVERY_SECONDS
self._proc: Popen[bytes] | None = None
def tick(self, now: float) -> None:
"""Reap a finished refresh, and start the next one when it is due."""
if self._proc is not None:
code = self._proc.poll()
if code is None:
return
if code != 0:
_logger.warning("the session refresh start exited %d", code)
self._proc = None
if now < self._due:
return
self._due = now + REFRESH_EVERY_SECONDS
try:
self._proc = _popen(REFRESH_COMMAND)
except OSError as exc:
_logger.warning("could not start the session refresh: %s", exc)
def main() -> int:
"""Show the strip while the morning is in progress; 0 when it is over."""
configure_logging(component="session-strip")
verdict = _verdict(read_entry(), _local_now())
if not isinstance(verdict, StripContent):
_logger.info("Nothing to show: %s", verdict)
return 0
try:
root = _make_root()
except tk.TclError as exc:
_logger.warning("Cannot open the display for the strip: %s", exc)
return 1
rect = corner_rect(enumerate_outputs(root))
if rect is None:
_logger.warning("Cannot place the strip: no live output on this display")
root.destroy()
return 1
strip = build_corner_strip(root, LockConfig(), verdict, rect=rect)
refresher = Refresher(start=_monotonic())
ending: list[str] = []
def supply() -> StripContent | None:
current = _verdict(read_entry(), _local_now())
if not isinstance(current, StripContent):
ending.append(current)
return None
refresher.tick(_monotonic())
return current
strip.schedule(supply, on_close=root.quit)
root.mainloop()
root.destroy()
_logger.info("Strip closed: %s", ending[0] if ending else "window gone")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -38,6 +38,8 @@ import freedays
import pytest
import sickdays
from wake_alarm import _session_strip
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
@ -49,6 +51,33 @@ def _no_process(cmd: list[str]) -> object:
raise AssertionError(msg)
class FakeClock:
"""A monotonic clock that only moves when ``sleep`` is called.
Pass ``clock.sleep`` and ``clock.now`` to ``_session.refresh`` so a dead
backend burns its 150 s retry budget instantly instead of for real.
"""
def __init__(self) -> None:
self.elapsed = 0.0
self.sleeps: list[float] = []
def now(self) -> float:
"""Seconds elapsed on the fake clock."""
return self.elapsed
def sleep(self, seconds: float) -> None:
"""Advance the clock without waiting."""
self.sleeps.append(seconds)
self.elapsed += seconds
@pytest.fixture
def fake_clock() -> FakeClock:
"""A fresh :class:`FakeClock`."""
return FakeClock()
@pytest.fixture(autouse=True)
def _hermetic(tmp_path: Path) -> Generator[None]:
"""Keep signing keys, state writes and processes inside ``tmp_path``."""
@ -67,6 +96,15 @@ def _hermetic(tmp_path: Path) -> Generator[None]:
"wake_alarm._session.MORNING_SESSION_FILE",
tmp_path / "morning_session.json",
),
# The strip imported the path by value too; its tick re-reads it every
# second, its refresh spawns systemctl, and its warn-once memory must
# not leak from one test into the next.
patch(
"wake_alarm._session_strip.MORNING_SESSION_FILE",
tmp_path / "morning_session.json",
),
patch("wake_alarm._session_strip._popen", _no_process),
patch("wake_alarm._session_strip._once", _session_strip._Once()),
patch(
"wake_alarm._workday_penalty.WORKDAY_PENALTY_FILE",
tmp_path / "workday_penalty.json",

View File

@ -0,0 +1,8 @@
{
"legs": [
{"name": "bathroomIn", "label": "Tap the bathroom tag"},
{"name": "showerDone", "label": "Shower, then tap the bathroom tag again"},
{"name": "dressed", "label": "Get dressed — tap the wardrobe tag"},
{"name": "atDesk", "label": "Sit down — tap the desk tag"}
]
}

View File

@ -172,12 +172,6 @@ class TestRefresh:
assert entry is not None
assert entry["exempt_until"] == _local("2026-09-19T06:45:00").isoformat()
def test_unreadable_backend_leaves_the_file_alone(self) -> None:
write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0))
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
assert refresh(NOW) is None
assert read_entry() is not None
def test_write_failure_is_none(self) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),

View File

@ -0,0 +1,30 @@
"""The leg names and labels the PC reads must equal the phone's enum.
``fixtures/session_legs.json`` is one literal both sides assert: this test
against ``_session_legs.LEGS`` and ``phone_app/test/services/
session_legs_fixture_test.dart`` against ``SessionLeg``. Each side's own
round-trip cannot catch a naming drift with its sibling.
"""
from __future__ import annotations
import json
from pathlib import Path
from wake_alarm._session_legs import LEGS, next_step
FIXTURE = Path(__file__).parent / "fixtures" / "session_legs.json"
def test_legs_equal_the_shared_fixture() -> None:
legs = json.loads(FIXTURE.read_text(encoding="utf-8"))["legs"]
assert [(leg["name"], leg["label"]) for leg in legs] == list(LEGS)
def test_next_step_is_the_first_leg_not_passed() -> None:
assert next_step(()) == "Tap the bathroom tag"
assert next_step(("bathroomIn",)) == "Shower, then tap the bathroom tag again"
# Order of the legs decides, not the order they were passed in.
assert next_step(("showerDone",)) == "Tap the bathroom tag"
assert next_step(("atDesk", "bathroomIn", "showerDone", "dressed")) is None
assert next_step(("unknownLeg",)) == "Tap the bathroom tag"

View File

@ -64,6 +64,26 @@ class TestRecordFromJson:
assert record.finished_at is None
assert not record.finished
def test_passed_legs_are_the_keys_with_a_timestamp(self) -> None:
passed = {
"bathroomIn": {"at": "2026-09-19T08:05:00.000", "how": "tag"},
"showerDone": "garbage",
"dressed": {"how": "tag"},
}
record = record_from_json(
_session("2026-09-19", "active", passed=passed),
device_id="p",
published_at=None,
)
assert record is not None
assert record.passed_legs == ("bathroomIn",)
for bad in (None, [], "x"):
record = record_from_json(
_session(passed=bad), device_id="p", published_at=None
)
assert record is not None
assert record.passed_legs == ()
def test_rejects_every_malformed_shape(self) -> None:
bad = [
"text",

View File

@ -0,0 +1,118 @@
"""``refresh`` retries an unreadable backend; ``next_step`` is signed."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from wake_alarm import _session
from wake_alarm._constants import REFRESH_POLL_SECONDS, REFRESH_RETRY_SECONDS
from wake_alarm._session import read_entry, refresh, to_entry, write_entry
from wake_alarm._session_payload import SessionRecord
from wake_alarm._sync import Alarm
if TYPE_CHECKING:
from wake_alarm.tests.conftest import FakeClock
NOW = datetime(2026, 9, 19, 8, 30).astimezone()
TODAY = "2026-09-19"
def _live(passed: tuple[str, ...] = ("bathroomIn",)) -> SessionRecord:
return SessionRecord(
date=TODAY,
outcome="in_progress",
started_at="2026-09-19T08:01:00.000",
finished_at=None,
energy=3,
weight_kg=73.8,
re_rings=0,
device_id="p",
published_at=1.0,
passed_legs=passed,
)
@pytest.fixture(autouse=True)
def _alarm() -> object:
with patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")):
yield
class TestRefreshRetry:
"""2026-11-07: booted without DNS; one try, then a 2-minute gap."""
def test_unreadable_backend_leaves_the_file_alone(
self, fake_clock: FakeClock
) -> None:
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
assert read_entry() is not None
def test_a_backend_that_recovers_is_retried_into_todays_entry(
self, fake_clock: FakeClock
) -> None:
with patch(
"wake_alarm._session.fetch_firebase_logs", side_effect=[None, None, []]
) as fetch:
entry = refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
assert entry is not None
assert (entry["date"], entry["outcome"]) == (TODAY, "none")
assert read_entry() == entry
assert fetch.call_count == 3
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS, REFRESH_POLL_SECONDS]
def test_a_dead_backend_gives_up_after_the_budget_and_says_so(
self, caplog: pytest.LogCaptureFixture, fake_clock: FakeClock
) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
caplog.at_level(logging.WARNING),
):
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS] * (
REFRESH_RETRY_SECONDS // REFRESH_POLL_SECONDS
)
warnings = [r.getMessage() for r in caplog.records if r.levelno >= 30]
assert any(f"{REFRESH_RETRY_SECONDS} s" in w for w in warnings)
def test_sick_day_cancel_runs_once_before_the_loop(
self, fake_clock: FakeClock
) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
patch("wake_alarm._session._cancel_for_sick_day") as cancel,
):
refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
cancel.assert_called_once()
class TestNextStep:
"""The entry names the next leg and signs it."""
def test_in_progress_entry_names_the_next_leg(self) -> None:
entry = to_entry(_live(), today=TODAY, refreshed_at=5.0)
assert entry["next_step"] == "Shower, then tap the bathroom tag again"
def test_next_step_is_null_unless_the_morning_is_live(self) -> None:
done = replace(_live(), outcome="completed", finished_at="2026-09-19T08:40")
assert to_entry(done, today=TODAY, refreshed_at=5.0)["next_step"] is None
failed = replace(_live(), outcome="failed")
assert to_entry(failed, today=TODAY, refreshed_at=5.0)["next_step"] is None
assert to_entry(None, today=TODAY, refreshed_at=5.0)["next_step"] is None
def test_tampering_next_step_breaks_verification(self) -> None:
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
assert read_entry() is not None
path = _session.MORNING_SESSION_FILE
tampered = json.loads(path.read_text())
tampered["next_step"] = None
path.write_text(json.dumps(tampered))
assert read_entry() is None

View File

@ -0,0 +1,128 @@
"""The corner strip's content rules, file reader and thin seams."""
from __future__ import annotations
from datetime import UTC, timedelta
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
from gatelock.corner_strip import StripContent
import pytest
from wake_alarm import _session_strip
from wake_alarm.tests.test_strip_support import NOW, STEP, signed
if TYPE_CHECKING:
from pathlib import Path
# conftest replaces _popen for every test; the real one is captured at import.
REAL_POPEN = _session_strip._popen
class TestStripContent:
def test_in_progress_says_next_step_and_when_locks_return(self) -> None:
assert _session_strip.strip_content(signed(), NOW) == StripContent(
"Morning session not finished",
(f"Next: {STEP}", "Locks return at 08:45"),
)
def test_next_step_null_drops_the_next_line(self) -> None:
content = _session_strip.strip_content(signed(next_step=None), NOW)
assert content == StripContent(
"Morning session not finished", ("Locks return at 08:45",)
)
def test_exempt_until_is_shown_in_local_time(self) -> None:
utc = (NOW + timedelta(minutes=45)).astimezone(UTC).isoformat()
content = _session_strip.strip_content(signed(exempt_until=utc), NOW)
assert content is not None
assert content.lines[-1] == "Locks return at 08:45"
@pytest.mark.parametrize("outcome", ["completed", "failed", "none", "weird"])
def test_other_outcomes_show_nothing(self, outcome: str) -> None:
assert _session_strip.strip_content(signed(outcome=outcome), NOW) is None
def test_yesterday_shows_nothing(self) -> None:
assert _session_strip.strip_content(signed(date="2026-11-07"), NOW) is None
def test_expired_shows_nothing(self) -> None:
later = NOW + timedelta(minutes=45)
assert _session_strip.strip_content(signed(), later) is None
def test_tampered_shows_nothing(self) -> None:
entry = signed()
entry["next_step"] = "Something else"
assert _session_strip.strip_content(entry, NOW) is None
def test_unsigned_shows_nothing(self) -> None:
entry = signed()
del entry["hmac"]
assert _session_strip.strip_content(entry, NOW) is None
def test_missing_entry_shows_nothing(self) -> None:
assert _session_strip.strip_content(None, NOW) is None
@pytest.mark.parametrize("until", [None, 5, "garbage", "2026-11-08T09:00:00"])
def test_unusable_exempt_until_shows_nothing(self, until: object) -> None:
assert _session_strip.strip_content(signed(exempt_until=until), NOW) is None
@pytest.mark.parametrize("step", [5, ""])
def test_unusable_next_step_is_left_out(self, step: object) -> None:
content = _session_strip.strip_content(signed(next_step=step), NOW)
assert content is not None
assert content.lines == ("Locks return at 08:45",)
class TestReadEntry:
def test_missing_file_is_none_and_quiet(
self, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.DEBUG):
assert _session_strip.read_entry() is None
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
def test_reads_the_file(self) -> None:
entry = signed()
_session_strip.MORNING_SESSION_FILE.write_text(json.dumps(entry))
assert _session_strip.read_entry() == entry
@pytest.mark.parametrize("text", ["{nope", "[1, 2]"])
def test_unreadable_file_warns_once_per_state(
self, text: str, caplog: pytest.LogCaptureFixture
) -> None:
_session_strip.MORNING_SESSION_FILE.write_text(text)
with caplog.at_level(logging.WARNING):
assert _session_strip.read_entry() is None
assert _session_strip.read_entry() is None
assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1
def test_directory_in_the_way_warns(self, caplog: pytest.LogCaptureFixture) -> None:
_session_strip.MORNING_SESSION_FILE.mkdir()
with caplog.at_level(logging.WARNING):
assert _session_strip.read_entry() is None
assert "Cannot read" in caplog.text
class TestHelpers:
def test_local_now_is_aware(self) -> None:
assert _session_strip._local_now().tzinfo is not None
def test_monotonic_moves_forward(self) -> None:
first = _session_strip._monotonic()
assert _session_strip._monotonic() >= first
def test_make_root_builds_a_withdrawn_tk_root(self) -> None:
with patch("wake_alarm._session_strip.tk.Tk") as tk_root:
root = _session_strip._make_root()
root.withdraw.assert_called_once()
assert root is tk_root.return_value
def test_popen_runs_the_command(self) -> None:
with patch("wake_alarm._session_strip.subprocess.Popen") as popen:
REAL_POPEN(["true"])
popen.assert_called_once_with(["true"])
def test_log_state_path_is_the_conftest_scratch(self, tmp_path: Path) -> None:
assert _session_strip.MORNING_SESSION_FILE.parent == tmp_path

View File

@ -0,0 +1,196 @@
"""The corner strip's refresher and its main loop, on a fake Tk."""
from __future__ import annotations
from contextlib import ExitStack
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
from gatelock.corner_strip import StripContent
import pytest
from wake_alarm import _session_strip
from wake_alarm.tests.test_strip_support import (
NOW,
STEP,
Display,
Procs,
signed,
write,
)
if TYPE_CHECKING:
from collections.abc import Generator
class TestRefresher:
@pytest.fixture
def procs(self) -> Procs:
fake = Procs()
with patch("wake_alarm._session_strip._popen", fake):
yield fake
def test_first_refresh_is_thirty_seconds_in(self, procs: Procs) -> None:
refresher = _session_strip.Refresher(start=100.0)
refresher.tick(129.0)
assert procs.started == []
refresher.tick(130.0)
assert procs.started == [
["systemctl", "--user", "start", "--no-block", "wake-alarm-session.service"]
]
def test_one_start_per_interval(self, procs: Procs) -> None:
refresher = _session_strip.Refresher(start=0.0)
for second in range(30, 60):
refresher.tick(float(second))
procs.procs[-1].poll.return_value = 0
assert len(procs.started) == 1
refresher.tick(60.0)
assert len(procs.started) == 2
def test_no_second_spawn_while_the_first_runs(self, procs: Procs) -> None:
refresher = _session_strip.Refresher(start=0.0)
refresher.tick(30.0)
refresher.tick(60.0)
refresher.tick(95.0)
assert len(procs.started) == 1
procs.procs[0].poll.return_value = 0
refresher.tick(96.0)
assert len(procs.started) == 2
def test_failed_exit_warns(
self, procs: Procs, caplog: pytest.LogCaptureFixture
) -> None:
refresher = _session_strip.Refresher(start=0.0)
refresher.tick(30.0)
procs.procs[0].poll.return_value = 5
with caplog.at_level(logging.WARNING):
refresher.tick(31.0)
assert "exited 5" in caplog.text
def test_spawn_failure_warns_and_waits_for_the_next_interval(
self, procs: Procs, caplog: pytest.LogCaptureFixture
) -> None:
procs.error = FileNotFoundError("systemctl")
refresher = _session_strip.Refresher(start=0.0)
with caplog.at_level(logging.WARNING):
refresher.tick(30.0)
refresher.tick(31.0)
assert caplog.text.count("could not start") == 1
procs.error = None
refresher.tick(60.0)
assert len(procs.started) == 1
@pytest.fixture
def display(monkeypatch: pytest.MonkeyPatch) -> Generator[Display]:
"""A fake Tk world with a pinned clock and a recording ``_popen``."""
fake = Display()
monkeypatch.setattr("wake_alarm._session_strip._local_now", lambda: NOW)
monkeypatch.setattr("wake_alarm._session_strip._monotonic", lambda: 0.0)
monkeypatch.setattr("wake_alarm._session_strip._popen", Procs())
with ExitStack() as stack:
for item in fake.patches():
stack.enter_context(item)
yield fake
class TestMain:
def test_nothing_to_show_exits_zero_without_a_window(
self, display: Display, caplog: pytest.LogCaptureFixture
) -> None:
write(signed(outcome="completed"))
with caplog.at_level(logging.INFO):
assert _session_strip.main() == 0
display.root.mainloop.assert_not_called()
assert display.built == []
assert "completed" in caplog.text
def test_missing_file_exits_zero(self, display: Display) -> None:
assert _session_strip.main() == 0
assert display.built == []
def test_no_output_is_a_warning_and_a_failure(
self, display: Display, caplog: pytest.LogCaptureFixture
) -> None:
write(signed())
display.rect = None
with caplog.at_level(logging.WARNING):
assert _session_strip.main() == 1
assert "no live output" in caplog.text
display.root.destroy.assert_called_once()
def test_shows_then_closes_when_the_session_completes(
self, display: Display
) -> None:
write(signed())
def flip(tick: int) -> None:
if tick == 3:
write(signed(outcome="completed", next_step=None))
display.on_tick = flip
assert _session_strip.main() == 0
assert display.built == [
StripContent(
"Morning session not finished",
(f"Next: {STEP}", "Locks return at 08:45"),
)
]
assert display.ticks == 3
display.window.destroy.assert_called_once()
display.root.quit.assert_called_once()
display.root.destroy.assert_called_once()
def test_closes_when_the_exemption_ends(
self, display: Display, monkeypatch: pytest.MonkeyPatch
) -> None:
write(signed())
def expire(tick: int) -> None:
if tick == 2:
monkeypatch.setattr(
"wake_alarm._session_strip._local_now",
lambda: NOW + timedelta(minutes=45),
)
display.on_tick = expire
assert _session_strip.main() == 0
assert display.ticks == 2
def test_refresh_starts_while_visible_and_not_after_close(
self, display: Display, monkeypatch: pytest.MonkeyPatch
) -> None:
procs = Procs()
clock = {"t": 0.0}
monkeypatch.setattr("wake_alarm._session_strip._popen", procs)
monkeypatch.setattr("wake_alarm._session_strip._monotonic", lambda: clock["t"])
write(signed())
def advance(tick: int) -> None:
clock["t"] = tick * 10.0
if tick == 4:
write(signed(outcome="completed"))
display.on_tick = advance
assert _session_strip.main() == 0
# Ticks run at t=10, 20, 30 (refresh due), then 40 closes the strip.
assert len(procs.started) == 1
def test_unopenable_display_is_logged_and_fails(
self, display: Display, caplog: pytest.LogCaptureFixture
) -> None:
import tkinter as tk
write(signed())
with (
patch(
"wake_alarm._session_strip._make_root",
side_effect=tk.TclError("no display"),
),
caplog.at_level(logging.WARNING),
):
assert _session_strip.main() == 1
assert "no display" in caplog.text

View File

@ -7,6 +7,7 @@ from datetime import date, datetime
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
@ -32,6 +33,9 @@ from wake_alarm._workday_penalty import (
write_penalty,
)
if TYPE_CHECKING:
from wake_alarm.tests.conftest import FakeClock
TUESDAY = date(2026, 9, 22)
AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone()
MISS: dict[str, object] = {
@ -131,10 +135,15 @@ class TestRetraction:
)
assert not cancel_for_sick_day()
def test_refresh_retracts_even_offline(self) -> None:
def test_refresh_retracts_even_offline(self, fake_clock: FakeClock) -> None:
mark_sick(TUESDAY)
with patch.object(_session, "fetch_firebase_logs", return_value=None):
assert _session.refresh(AFTER_GRACE) is None
assert (
_session.refresh(
AFTER_GRACE, sleep=fake_clock.sleep, clock=fake_clock.now
)
is None
)
assert issued_date() is None

View File

@ -0,0 +1,103 @@
"""Shared fakes for the corner-strip tests: signed entries, Tk and Popen."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from gatelock.corner_strip import CornerStrip, StripContent
from gatelock.log_integrity import compute_entry_hmac
from wake_alarm import _session_strip
if TYPE_CHECKING:
from collections.abc import Callable
TZ = timezone(timedelta(hours=1))
NOW = datetime(2026, 11, 8, 8, 0, tzinfo=TZ)
STEP = "Shower, then tap the bathroom tag again"
def signed(**overrides: object) -> dict[str, object]:
"""A verified in-progress entry for NOW's day, with fields overridden."""
entry: dict[str, object] = {
"date": "2026-11-08",
"outcome": "in_progress",
"exempt_until": (NOW + timedelta(minutes=45)).isoformat(),
"next_step": STEP,
}
entry.update(overrides)
entry["hmac"] = compute_entry_hmac(entry)
return entry
class Procs:
"""A fake ``_popen`` recording starts; each proc finishes when told."""
def __init__(self) -> None:
self.started: list[list[str]] = []
self.procs: list[MagicMock] = []
self.error: OSError | None = None
def __call__(self, cmd: list[str]) -> MagicMock:
if self.error is not None:
raise self.error
self.started.append(cmd)
proc = MagicMock()
proc.poll.return_value = None
self.procs.append(proc)
return proc
class Display:
"""A fake Tk world: records the strip's tick callbacks and drives them."""
def __init__(self) -> None:
self.root = MagicMock(name="root")
self.window = MagicMock(name="window")
self.callbacks: list[Callable[[], None]] = []
self.window.after.side_effect = self._after
self.root.quit.side_effect = self.callbacks.clear
self.root.mainloop.side_effect = self._mainloop
self.ticks = 0
self.on_tick: Callable[[int], None] = lambda _n: None
self.rect: object = MagicMock(name="rect")
self.built: list[StripContent] = []
def _after(self, _ms: int, callback: Callable[[], None]) -> str:
self.callbacks.append(callback)
return "job"
def _mainloop(self) -> None:
while self.callbacks:
self.ticks += 1
self.on_tick(self.ticks)
self.callbacks.pop(0)()
if self.ticks > 50: # runaway-loop guard
msg = "strip never closed"
raise AssertionError(msg)
def build(
self, _root: object, _config: object, content: StripContent, **kwargs: object
) -> object:
assert kwargs["rect"] is self.rect
self.built.append(content)
return CornerStrip(self.window, MagicMock(), [MagicMock(), MagicMock()])
def patches(self) -> list[object]:
return [
patch("wake_alarm._session_strip._make_root", return_value=self.root),
patch("wake_alarm._session_strip.enumerate_outputs"),
patch(
"wake_alarm._session_strip.corner_rect",
side_effect=lambda _scan: self.rect,
),
patch("wake_alarm._session_strip.build_corner_strip", self.build),
]
def write(entry: dict[str, object]) -> None:
_session_strip.MORNING_SESSION_FILE.write_text(json.dumps(entry))