mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:07:03 +01:00
Compare commits
15 Commits
v1.0.0-bui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e6eed105 | |||
| 06b70ab312 | |||
| 10938ed012 | |||
| f182453567 | |||
| 537a3a79e4 | |||
| 672eaf8aba | |||
| 13f6e3f5d2 | |||
| 5525ca6f95 | |||
| 58c4260b6e | |||
| 6033e3815f | |||
| 18a304de55 | |||
| a51a67e2fe | |||
| fd65c454e3 | |||
| 05738db15a | |||
| 69be24d4c0 |
2
.github/workflows/python-tests.yml
vendored
2
.github/workflows/python-tests.yml
vendored
@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
working-directory: phone_app
|
||||
|
||||
- name: Dart writes, Python replays, both must agree
|
||||
|
||||
2
.github/workflows/release-apk.yml
vendored
2
.github/workflows/release-apk.yml
vendored
@ -87,7 +87,7 @@ jobs:
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
|
||||
@ -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.
|
||||
#
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -25,6 +25,10 @@ weigh in -> bathroom tag (≤ 2 min later) -> shower -> bathroom tag again
|
||||
| `dressed` | wardrobe tag | previous + 10 min | |
|
||||
| `atDesk` | desk tag | previous + 10 min | |
|
||||
|
||||
**A later tag catches up a forgotten one** (`LegPass.caughtUp`, 2026-11-01:
|
||||
shower tag forgotten, re-rung while dressing). The weigh-in never catches
|
||||
up, and the shower's 8-minute floor still applies.
|
||||
|
||||
Legs latch like the ring's stages. A leg whose tag is not registered is
|
||||
**skipped at start** (`LegPass.skipped`), so the feature is usable before it
|
||||
is configured: with no session tags at all it is a weigh-in and nothing
|
||||
@ -58,6 +62,8 @@ the first leg by itself — there is nothing to walk to.
|
||||
- **Silencing a re-ring is the full dismiss sequence**: tag 1, the walk,
|
||||
tag 2, three questions. The ring screen's `_finishDismiss` sees a live
|
||||
session for today and resumes it with `reRings + 1`.
|
||||
- **A silent buzz 3 min before each deadline** (`SessionWarn.kt`, request
|
||||
code 1004), armed and cleared only with the re-ring.
|
||||
- **One re-ring per leg.** After a deadline re-ring is dismissed the leg is
|
||||
in `deadlineRung`; 5 min after its deadline (`bypassGrace`) it passes as
|
||||
`LegPass.bypassed` — the dead-tag hatch, paid for with one full ring.
|
||||
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -94,6 +94,10 @@
|
||||
<receiver
|
||||
android:name=".PoolSyncReceiver"
|
||||
android:exported="false"/>
|
||||
<!-- The silent buzz before a session deadline (SessionWarn.kt). -->
|
||||
<receiver
|
||||
android:name=".SessionWarnReceiver"
|
||||
android:exported="false"/>
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
|
||||
@ -119,6 +119,7 @@ object AlarmScheduler {
|
||||
AlarmManager.AlarmClockInfo(atMillis, showIntent(context)),
|
||||
reRingPendingIntent(context),
|
||||
)
|
||||
SessionWarn.arm(context, atMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -129,6 +130,7 @@ object AlarmScheduler {
|
||||
fun clearReRing(context: Context) {
|
||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
manager.cancel(reRingPendingIntent(context))
|
||||
SessionWarn.clear(context)
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_RERING_AT).apply()
|
||||
}
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
package com.kuhy.wake_alarm_sync
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.os.Build
|
||||
import android.os.VibrationAttributes
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* A silent buzz [LEAD_MS] before each morning-session deadline.
|
||||
*
|
||||
* Armed and cleared only alongside the re-ring in [AlarmScheduler], so it
|
||||
* cannot outlive or drift from the deadline it warns about.
|
||||
*/
|
||||
object SessionWarn {
|
||||
private const val TAG = "WakeAlarmSession"
|
||||
|
||||
/** Distinct from 1001 (daily) and 1003 (re-ring): PendingIntents key on it. */
|
||||
private const val REQUEST_CODE = 1004
|
||||
|
||||
const val LEAD_MS = 3 * 60_000L
|
||||
|
||||
private val PATTERN = longArrayOf(0, 400, 200, 400, 200, 400)
|
||||
|
||||
/** The buzz instant, or null once it has passed (BOOT_COMPLETED replays). */
|
||||
fun warnAt(deadlineMs: Long, nowMs: Long): Long? =
|
||||
(deadlineMs - LEAD_MS).takeIf { it > nowMs }
|
||||
|
||||
fun arm(context: Context, deadlineMs: Long) {
|
||||
val at = warnAt(deadlineMs, System.currentTimeMillis())
|
||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
if (at == null) {
|
||||
manager.cancel(pendingIntent(context))
|
||||
return
|
||||
}
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pendingIntent(context))
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
manager.cancel(pendingIntent(context))
|
||||
}
|
||||
|
||||
internal fun buzz(context: Context) {
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)
|
||||
?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
} ?: return
|
||||
val effect = VibrationEffect.createWaveform(PATTERN, -1)
|
||||
// Alarm usage, so Do Not Disturb and a backgrounded app still buzz.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
vibrator.vibrate(effect, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ALARM))
|
||||
} else {
|
||||
val audio = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build()
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(effect, audio)
|
||||
}
|
||||
Log.i(TAG, "deadline warning buzzed")
|
||||
}
|
||||
|
||||
private fun pendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
REQUEST_CODE,
|
||||
Intent(context, SessionWarnReceiver::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
/** Buzzes only while today's session is live; a stale alarm stays silent. */
|
||||
class SessionWarnReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (!SessionNotice.isLiveToday(context) || AlarmService.running) return
|
||||
SessionWarn.buzz(context)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.kuhy.wake_alarm_sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SessionWarnTest {
|
||||
private val deadline = 1_000_000_000L
|
||||
|
||||
@Test
|
||||
fun warnsThreeMinutesBeforeTheDeadline() {
|
||||
assertEquals(deadline - 180_000L, SessionWarn.warnAt(deadline, deadline - 600_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWarnInstantAlreadyPastIsDropped() {
|
||||
assertNull(SessionWarn.warnAt(deadline, deadline - 60_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeadlineAlreadyPastIsDropped() {
|
||||
assertNull(SessionWarn.warnAt(deadline, deadline + 1L))
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -204,7 +204,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onPressed: () async {
|
||||
await Navigator.push<void>(
|
||||
context,
|
||||
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => SettingsScreen(alarmService: _service),
|
||||
),
|
||||
);
|
||||
// Publish too, not just reload: a tag registered in Settings
|
||||
// reaches the backend now, not on the next cold start.
|
||||
|
||||
107
phone_app/lib/screens/home_tiles.dart
Normal file
107
phone_app/lib/screens/home_tiles.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||
|
||||
@ -5,6 +7,8 @@ import 'package:wake_alarm_sync/screens/github_mirror_screen.dart';
|
||||
import 'package:wake_alarm_sync/screens/home_location_screen.dart';
|
||||
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';
|
||||
import 'package:wake_alarm_sync/screens/session_tags_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
import 'package:wake_alarm_sync/services/data_transfer.dart';
|
||||
import 'package:wake_alarm_sync/services/firebase_backend.dart';
|
||||
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
|
||||
|
||||
@ -19,12 +23,77 @@ import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
|
||||
/// the alarm can skip them when it rings somewhere else.
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
/// Creates a [SettingsScreen].
|
||||
const SettingsScreen({super.key});
|
||||
const SettingsScreen({
|
||||
super.key,
|
||||
this.alarmService,
|
||||
this.onExport,
|
||||
this.onImport,
|
||||
});
|
||||
|
||||
/// The home screen's live alarm store, reused by export/import.
|
||||
final AlarmSyncService? alarmService;
|
||||
|
||||
/// Export action; defaults to [saveExport].
|
||||
final Future<void> Function()? onExport;
|
||||
|
||||
/// Import action, false on cancel; defaults to [pickAndImport].
|
||||
final Future<bool> Function()? onImport;
|
||||
|
||||
Future<void> _run(
|
||||
BuildContext context,
|
||||
Future<bool> Function() action,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
var ok = false;
|
||||
try {
|
||||
if (!await action()) return;
|
||||
ok = true;
|
||||
} on Object catch (e, stack) {
|
||||
developer.log(
|
||||
'data transfer failed: $e',
|
||||
name: 'WakeAlarmSync',
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Icon(
|
||||
ok ? Icons.check_circle : Icons.error_outline,
|
||||
color: ok ? colors.primary : colors.error,
|
||||
semanticLabel: ok ? 'Done' : 'Failed',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const Key('settings-export'),
|
||||
icon: const Icon(Icons.upload_file, semanticLabel: 'Export data'),
|
||||
onPressed: () => _run(context, () async {
|
||||
await (onExport ?? () => saveExport(alarm: alarmService))();
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('settings-import'),
|
||||
icon: const Icon(Icons.download, semanticLabel: 'Import data'),
|
||||
onPressed: () => _run(
|
||||
context,
|
||||
onImport ?? () => pickAndImport(alarm: alarmService),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -117,6 +117,12 @@ class AlarmSyncService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The whole log, for the user-facing export.
|
||||
Log snapshot() => _store.snapshot();
|
||||
|
||||
/// Replaces the log with a merged result and persists it (import).
|
||||
Future<void> replaceAll(Log merged) => _store.replaceAll(merged);
|
||||
|
||||
/// Stores [hour]:[minute] locally, stamped with a fresh monotonic HLC.
|
||||
///
|
||||
/// Both fields share one clock tick — they are always set together, so a
|
||||
|
||||
72
phone_app/lib/services/data_export.dart
Normal file
72
phone_app/lib/services/data_export.dart
Normal file
@ -0,0 +1,72 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
|
||||
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||
|
||||
/// One crdt log the export covers.
|
||||
typedef LogSlot = ({
|
||||
Log Function() snapshot,
|
||||
Future<void> Function(Log merged) replaceAll,
|
||||
});
|
||||
|
||||
/// Marks the file as this app's export.
|
||||
const exportFormat = 'wake_alarm_export';
|
||||
|
||||
/// Shape version.
|
||||
const exportVersion = 1;
|
||||
|
||||
/// Every slot's log plus the ramp dates as JSON; no device id or secrets.
|
||||
Future<String> buildExport(Map<String, LogSlot> slots, {DateTime? now}) async {
|
||||
final dates = (await RampProgress.load()).toList()..sort();
|
||||
return const JsonEncoder.withIndent(' ').convert(
|
||||
buildEnvelope(
|
||||
format: exportFormat,
|
||||
version: exportVersion,
|
||||
now: now ?? DateTime.now(),
|
||||
body: <String, Object?>{
|
||||
'logs': <String, Object?>{
|
||||
for (final entry in slots.entries)
|
||||
entry.key: jsonDecode(logToJson(entry.value.snapshot())),
|
||||
},
|
||||
'ramp_earned_dates': dates,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Merges via [mergeLogs] only, after validating all; throws [FormatException].
|
||||
Future<void> importExport(String text, Map<String, LogSlot> slots) async {
|
||||
final parsed = _parse(text);
|
||||
for (final entry in parsed.logs.entries) {
|
||||
final slot = slots[entry.key];
|
||||
if (slot == null) continue;
|
||||
await slot.replaceAll(mergeLogs(slot.snapshot(), entry.value));
|
||||
}
|
||||
await RampProgress.merge(parsed.rampDates);
|
||||
}
|
||||
|
||||
typedef _Parsed = ({Map<String, Log> logs, List<String> rampDates});
|
||||
|
||||
_Parsed _parse(String text) {
|
||||
final decoded = parseEnvelope(
|
||||
text,
|
||||
format: exportFormat,
|
||||
version: exportVersion,
|
||||
);
|
||||
final rawLogs = decoded['logs'];
|
||||
if (rawLogs is! Map<String, dynamic>) {
|
||||
throw const FormatException('export has no "logs" object');
|
||||
}
|
||||
final logs = <String, Log>{};
|
||||
for (final entry in rawLogs.entries) {
|
||||
try {
|
||||
logs[entry.key] = logFromJson(jsonEncode(entry.value));
|
||||
} on Object catch (error) {
|
||||
throw FormatException('log "${entry.key}" is malformed ($error)');
|
||||
}
|
||||
}
|
||||
final rawDates = decoded['ramp_earned_dates'];
|
||||
final dates = rawDates is List ? rawDates.whereType<String>() : <String>[];
|
||||
return (logs: logs, rampDates: dates.toList());
|
||||
}
|
||||
38
phone_app/lib/services/data_transfer.dart
Normal file
38
phone_app/lib/services/data_transfer.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
import 'package:wake_alarm_sync/services/data_export.dart';
|
||||
import 'package:wake_alarm_sync/services/free_day_pool.dart';
|
||||
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
|
||||
|
||||
/// The three logs; pass the live [alarm] so no stale twin overwrites it.
|
||||
Future<Map<String, LogSlot>> openLogSlots({AlarmSyncService? alarm}) async {
|
||||
final alarmStore = alarm ?? await AlarmSyncService.open();
|
||||
final free = await FreeDayPool.open();
|
||||
final sick = await SickDayPool.open();
|
||||
return {
|
||||
'alarm': (snapshot: alarmStore.snapshot, replaceAll: alarmStore.replaceAll),
|
||||
'free_days': (snapshot: free.snapshot, replaceAll: free.replaceAll),
|
||||
'sick_days': (snapshot: sick.snapshot, replaceAll: sick.replaceAll),
|
||||
};
|
||||
}
|
||||
|
||||
/// Saves the export to device storage via the system Save-as picker.
|
||||
///
|
||||
/// False when the picker was cancelled.
|
||||
Future<bool> saveExport({AlarmSyncService? alarm}) async {
|
||||
final now = DateTime.now();
|
||||
final json = await buildExport(await openLogSlots(alarm: alarm), now: now);
|
||||
final day = now.toIso8601String().substring(0, 10);
|
||||
return await exportJsonText(
|
||||
fileName: 'wake-alarm-export-$day.json',
|
||||
text: json,
|
||||
);
|
||||
}
|
||||
|
||||
/// Picks an export and merges it; false when the picker was cancelled.
|
||||
Future<bool> pickAndImport({AlarmSyncService? alarm}) async {
|
||||
final text = await pickJsonText();
|
||||
if (text == null) return false;
|
||||
await importExport(text, await openLogSlots(alarm: alarm));
|
||||
return true;
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -43,6 +43,9 @@ enum LegPass {
|
||||
|
||||
/// Never satisfied; passed by the clock after a missed deadline.
|
||||
bypassed,
|
||||
|
||||
/// Forgotten, then covered by a later room's tag once its floor had passed.
|
||||
caughtUp,
|
||||
}
|
||||
|
||||
/// Where the session stands.
|
||||
|
||||
@ -84,7 +84,17 @@ extension SessionRules on MorningSession {
|
||||
String onTag(SessionTagSlot slot, DateTime now) {
|
||||
final leg = currentLeg;
|
||||
if (leg == null) return 'Session finished';
|
||||
if (slot != leg.slot) return 'Not the ${leg.slot.name} tag';
|
||||
if (slot != leg.slot) {
|
||||
final ahead = _catchUpTo(slot, now);
|
||||
if (ahead == null) return 'Not the ${leg.slot.name} tag';
|
||||
for (var i = leg.index; i < ahead.index; i++) {
|
||||
final skipped = SessionLeg.values[i];
|
||||
if (!passedAt.containsKey(skipped)) {
|
||||
_pass(skipped, now, LegPass.caughtUp);
|
||||
}
|
||||
}
|
||||
return onTag(slot, now);
|
||||
}
|
||||
switch (leg) {
|
||||
case SessionLeg.bathroomIn:
|
||||
final typedAt = pendingWeightAt;
|
||||
@ -175,6 +185,20 @@ extension SessionRules on MorningSession {
|
||||
return deadline(leg);
|
||||
}
|
||||
|
||||
/// The later leg [slot] covers; the weigh-in and shower floor never skip.
|
||||
SessionLeg? _catchUpTo(SessionTagSlot slot, DateTime now) {
|
||||
final leg = currentLeg!;
|
||||
for (var i = leg.index; i < SessionLeg.values.length; i++) {
|
||||
final next = SessionLeg.values[i];
|
||||
if (i > leg.index && next.slot == slot) return next;
|
||||
if (passedAt.containsKey(next)) continue;
|
||||
if (next == SessionLeg.bathroomIn) return null;
|
||||
final floor = earliest(next);
|
||||
if (floor != null && now.isBefore(floor)) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _pass(SessionLeg leg, DateTime now, LegPass how) {
|
||||
passedAt[leg] = now;
|
||||
passedHow[leg] = how;
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
android_file_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: android_file_picker
|
||||
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
android_intent_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -93,19 +101,27 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.11.1"
|
||||
resolved-ref: "978d32af3af66f0d6500c4c6c0c6df5370ebe9a2"
|
||||
ref: "crdt_sync_dart-v0.12.0"
|
||||
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.11.1"
|
||||
version: "0.12.0"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+5"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
version: "3.0.8"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -114,6 +130,23 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
data_transfer_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: data_transfer_flutter
|
||||
ref: "data_transfer_flutter-v0.2.1"
|
||||
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.2.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -146,6 +179,110 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.2"
|
||||
file_picker_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_darwin
|
||||
sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
file_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_linux
|
||||
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
file_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_platform_interface
|
||||
sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
file_picker_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_web
|
||||
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
file_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector
|
||||
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
file_selector_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_android
|
||||
sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2+11"
|
||||
file_selector_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_ios
|
||||
sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3+7"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4+1"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5+2"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_web
|
||||
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+6"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -234,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:
|
||||
@ -394,6 +492,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -514,6 +620,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
share_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: db40767f130189d53f3ae8a6393c217ff4a492da1baff718a9873679c0c00224
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.1"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -611,11 +733,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: sync_settings_ui
|
||||
ref: "sync_settings_ui-v0.4.1"
|
||||
resolved-ref: "51700aa89eaf1789ae98491aaa854c54d35b4170"
|
||||
ref: "sync_settings_ui-v0.6.1"
|
||||
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.4.1"
|
||||
version: "0.6.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -752,6 +874,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.0"
|
||||
windows_file_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: windows_file_picker
|
||||
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -778,4 +908,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
flutter: ">=3.47.0"
|
||||
|
||||
@ -12,9 +12,16 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.11.1
|
||||
ref: crdt_sync_dart-v0.12.0
|
||||
path: crdt_sync_dart
|
||||
cupertino_icons: 2.0.0
|
||||
|
||||
# Shared JSON export/import plumbing (Save-as, picker, envelope).
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.1
|
||||
path: data_transfer_flutter
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_secure_storage: 11.2.0
|
||||
@ -26,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
|
||||
@ -38,7 +49,7 @@ dependencies:
|
||||
sync_settings_ui:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: sync_settings_ui-v0.4.1
|
||||
ref: sync_settings_ui-v0.6.1
|
||||
path: sync_settings_ui
|
||||
url_launcher: 6.3.3
|
||||
uuid: 4.6.0
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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.
|
||||
|
||||
19
phone_app/test/google_sign_in_supported_test.dart
Normal file
19
phone_app/test/google_sign_in_supported_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
120
phone_app/test/home_fit_test.dart
Normal file
120
phone_app/test/home_fit_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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++) {
|
||||
|
||||
121
phone_app/test/services/data_export_test.dart
Normal file
121
phone_app/test/services/data_export_test.dart
Normal file
@ -0,0 +1,121 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
import 'package:wake_alarm_sync/services/data_export.dart';
|
||||
import 'package:wake_alarm_sync/services/free_day_pool.dart';
|
||||
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
|
||||
|
||||
import '../fake_day_pool_store.dart';
|
||||
|
||||
final _now = DateTime(2026, 11, 2);
|
||||
|
||||
/// One install's three stores, on in-memory persistence.
|
||||
class _Device {
|
||||
_Device._(this.alarm, this.free, this.sick);
|
||||
|
||||
final AlarmSyncService alarm;
|
||||
final FreeDayPool free;
|
||||
final SickDayPool sick;
|
||||
|
||||
static Future<_Device> open(String id) async => _Device._(
|
||||
await AlarmSyncService.open(persistence: MemPersistence(), deviceId: id),
|
||||
await FreeDayPool.open(persistence: MemPersistence(), deviceId: id),
|
||||
await SickDayPool.open(persistence: MemPersistence(), deviceId: id),
|
||||
);
|
||||
|
||||
Map<String, LogSlot> get slots => {
|
||||
'alarm': (snapshot: alarm.snapshot, replaceAll: alarm.replaceAll),
|
||||
'free_days': (snapshot: free.snapshot, replaceAll: free.replaceAll),
|
||||
'sick_days': (snapshot: sick.snapshot, replaceAll: sick.replaceAll),
|
||||
};
|
||||
|
||||
/// Every log as plain JSON, so equality is structural.
|
||||
Map<String, Object?> get state => {
|
||||
for (final e in slots.entries)
|
||||
e.key: jsonDecode(logToJson(e.value.snapshot())),
|
||||
};
|
||||
}
|
||||
|
||||
Future<_Device> _populated() async {
|
||||
final device = await _Device.open('phone-a');
|
||||
await device.alarm.setLocal(7, 42);
|
||||
await device.free.mark(DateTime(2026, 11, 3), reason: 'trip', now: _now);
|
||||
await device.sick.add(
|
||||
DateTime(2026, 11, 5),
|
||||
isFree: device.free.isFree,
|
||||
now: _now,
|
||||
);
|
||||
return device;
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
|
||||
|
||||
test(
|
||||
'export -> import into an empty store reproduces every record',
|
||||
() async {
|
||||
await RampProgress.merge(['2026-10-25', '2026-10-26']);
|
||||
final source = await _populated();
|
||||
final file = await buildExport(source.slots, now: _now);
|
||||
|
||||
final decoded = jsonDecode(file) as Map<String, dynamic>;
|
||||
expect(decoded['format'], 'wake_alarm_export');
|
||||
expect(decoded['version'], 1);
|
||||
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final target = await _Device.open('phone-b');
|
||||
await importExport(file, target.slots);
|
||||
|
||||
expect(target.state, equals(source.state));
|
||||
expect(await RampProgress.load(), {'2026-10-25', '2026-10-26'});
|
||||
},
|
||||
);
|
||||
|
||||
test('importing the same file twice equals importing it once', () async {
|
||||
final file = await buildExport((await _populated()).slots, now: _now);
|
||||
final target = await _Device.open('phone-b');
|
||||
|
||||
await importExport(file, target.slots);
|
||||
final once = target.state;
|
||||
await importExport(file, target.slots);
|
||||
|
||||
expect(target.state, equals(once));
|
||||
});
|
||||
|
||||
test('import into a non-empty store keeps the existing records', () async {
|
||||
final file = await buildExport((await _populated()).slots, now: _now);
|
||||
await RampProgress.merge(['2026-10-18']);
|
||||
final target = await _Device.open('phone-b');
|
||||
await target.free.mark(DateTime(2026, 11, 9), now: _now);
|
||||
final before = target.state;
|
||||
|
||||
await importExport(file, target.slots);
|
||||
|
||||
expect(target.free.isFree(DateTime(2026, 11, 9)), isTrue);
|
||||
expect(target.free.isFree(DateTime(2026, 11, 3)), isTrue);
|
||||
expect(target.sick.isSick(DateTime(2026, 11, 5)), isTrue);
|
||||
expect(target.alarm.currentAlarm, (hour: 7, minute: 42));
|
||||
final freeBefore = (before['free_days']! as Map)['2026-11-09'];
|
||||
expect((target.state['free_days']! as Map)['2026-11-09'], freeBefore);
|
||||
expect(await RampProgress.load(), contains('2026-10-18'));
|
||||
});
|
||||
|
||||
test('a file that is not a wake_alarm export changes nothing', () async {
|
||||
final target = await _populated();
|
||||
final before = target.state;
|
||||
|
||||
for (final bad in [
|
||||
'not json',
|
||||
'[]',
|
||||
'{"days": []}',
|
||||
'{"format": "wake_alarm_export", "version": 1, "logs": {"alarm": 3}}',
|
||||
]) {
|
||||
await expectLater(importExport(bad, target.slots), throwsFormatException);
|
||||
}
|
||||
expect(target.state, equals(before));
|
||||
});
|
||||
}
|
||||
69
phone_app/test/services/session_catch_up_test.dart
Normal file
69
phone_app/test/services/session_catch_up_test.dart
Normal file
@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/morning_session.dart';
|
||||
import 'package:wake_alarm_sync/services/session_json.dart';
|
||||
import 'package:wake_alarm_sync/services/session_rules.dart';
|
||||
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
|
||||
|
||||
final DateTime t0 = DateTime(2026, 9, 17, 8, 5);
|
||||
const SessionTagSettings allTags = SessionTagSettings(
|
||||
tagIds: <String>['bath', 'ward', 'desk'],
|
||||
);
|
||||
|
||||
MorningSession fresh({SessionTagSettings tags = allTags, bool nfc = true}) =>
|
||||
SessionRules.start(now: t0, tags: tags, nfcAvailable: nfc);
|
||||
|
||||
/// Weighs in and taps the bathroom tag, leaving the shower leg live.
|
||||
MorningSession inBathroom() => fresh()
|
||||
..enterCheckIn(72, 3, t0)
|
||||
..onTag(SessionTagSlot.bathroom, t0.add(const Duration(seconds: 30)));
|
||||
|
||||
void main() {
|
||||
group('catch-up', () {
|
||||
test('a later tag catches up a forgotten shower tag', () {
|
||||
final s = inBathroom();
|
||||
final late = s
|
||||
.earliest(SessionLeg.showerDone)!
|
||||
.add(const Duration(minutes: 4));
|
||||
expect(
|
||||
s.onTag(SessionTagSlot.wardrobe, late),
|
||||
'Done: ${SessionLeg.dressed.label}',
|
||||
);
|
||||
expect(s.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
|
||||
expect(s.passedHow[SessionLeg.dressed], LegPass.tag);
|
||||
expect(s.currentLeg, SessionLeg.atDesk);
|
||||
expect(s.nextDeadline(), late.add(MorningSession.legWindow));
|
||||
expect(s.reRings, 0);
|
||||
});
|
||||
|
||||
test('the desk tag catches up every leg before it', () {
|
||||
final s = inBathroom();
|
||||
final late = s.earliest(SessionLeg.showerDone)!;
|
||||
expect(s.onTag(SessionTagSlot.desk, late), contains('complete'));
|
||||
expect(s.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
|
||||
expect(s.passedHow[SessionLeg.dressed], LegPass.caughtUp);
|
||||
expect(s.outcome, SessionOutcome.completed);
|
||||
});
|
||||
|
||||
test('catch-up still waits out the shower floor', () {
|
||||
final s = inBathroom();
|
||||
final early = s
|
||||
.earliest(SessionLeg.showerDone)!
|
||||
.subtract(const Duration(seconds: 1));
|
||||
expect(s.onTag(SessionTagSlot.wardrobe, early), 'Not the bathroom tag');
|
||||
expect(s.currentLeg, SessionLeg.showerDone);
|
||||
});
|
||||
|
||||
test('the weigh-in is never caught up', () {
|
||||
final s = fresh()..enterCheckIn(72, 3, t0);
|
||||
expect(s.onTag(SessionTagSlot.desk, t0), 'Not the bathroom tag');
|
||||
expect(s.currentLeg, SessionLeg.bathroomIn);
|
||||
});
|
||||
|
||||
test('a caught-up leg survives the store', () {
|
||||
final s = inBathroom()
|
||||
..onTag(SessionTagSlot.wardrobe, t0.add(const Duration(minutes: 9)));
|
||||
final back = sessionFromJson(s.toJson())!;
|
||||
expect(back.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
|
||||
});
|
||||
});
|
||||
}
|
||||
24
phone_app/test/services/session_legs_fixture_test.dart
Normal file
24
phone_app/test/services/session_legs_fixture_test.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
94
phone_app/test/session_screen_catch_up_test.dart
Normal file
94
phone_app/test/session_screen_catch_up_test.dart
Normal file
@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:wake_alarm_sync/screens/session_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/morning_session.dart';
|
||||
import 'package:wake_alarm_sync/services/session_store.dart';
|
||||
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late List<MethodCall> calls;
|
||||
late DateTime now;
|
||||
var ringing = false;
|
||||
|
||||
void mockChannel() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(AlarmScheduler.channelName),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
if (call.method == 'hasNfc') return true;
|
||||
if (call.method == 'isRinging') return ringing;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Delivers a tag from the platform side, as `NfcReader` does.
|
||||
Future<void> tap(String tagId) async {
|
||||
const codec = StandardMethodCodec();
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
AlarmScheduler.channelName,
|
||||
codec.encodeMethodCall(MethodCall('onNfcTag', tagId)),
|
||||
(_) {},
|
||||
);
|
||||
}
|
||||
|
||||
List<String> methods() => calls.map((c) => c.method).toList();
|
||||
|
||||
setUp(() async {
|
||||
calls = <MethodCall>[];
|
||||
now = DateTime(2026, 9, 17, 8, 4);
|
||||
ringing = false;
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
mockChannel();
|
||||
await SessionTagSettings.replaceAll(<String>['bath', 'ward', 'desk'], '');
|
||||
});
|
||||
|
||||
Future<void> pumpSession(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
routes: <String, WidgetBuilder>{
|
||||
'/': (_) =>
|
||||
SessionScreen(clock: () => now, publish: () async => 'pushed'),
|
||||
'/ring': (_) => const Scaffold(body: Text('RING')),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// Taps energy 3 (required since the check-in) and submits [kg].
|
||||
Future<void> logWeight(WidgetTester tester, String kg) async {
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, '3'));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), kg);
|
||||
await tester.tap(find.text('Log weight'));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('a forgotten shower tag is caught up by the wardrobe', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpSession(tester);
|
||||
await logWeight(tester, '72');
|
||||
await tap('bath');
|
||||
await tester.pump();
|
||||
now = now.add(const Duration(minutes: 9));
|
||||
await tap('ward');
|
||||
await tester.pump();
|
||||
final saved = (await SessionStore().loadCurrent())!;
|
||||
expect(saved.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
|
||||
expect(saved.currentLeg, SessionLeg.atDesk);
|
||||
final arm = calls.lastWhere((c) => c.method == 'armReRing');
|
||||
final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch;
|
||||
expect(arm.arguments, <String, int?>{'atMillis': deadline});
|
||||
expect(methods(), isNot(contains('ringNow')));
|
||||
});
|
||||
}
|
||||
@ -21,4 +21,82 @@ void main() {
|
||||
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('export and import are icon-only, labelled, and report back', (
|
||||
tester,
|
||||
) async {
|
||||
var exported = 0;
|
||||
var imported = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SettingsScreen(
|
||||
onExport: () async => exported++,
|
||||
onImport: () async {
|
||||
imported++;
|
||||
throw const FormatException('bad file');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Export data'));
|
||||
await tester.pump();
|
||||
expect(exported, 1);
|
||||
expect(find.byIcon(Icons.check_circle), findsOneWidget);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Import data'));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 5));
|
||||
expect(imported, 1);
|
||||
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a successful import shows the check icon', (tester) async {
|
||||
var imported = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SettingsScreen(
|
||||
onImport: () async {
|
||||
imported++;
|
||||
return true;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Import data'));
|
||||
await tester.pump();
|
||||
expect(imported, 1);
|
||||
expect(find.byIcon(Icons.check_circle), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a cancelled import shows no feedback', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: SettingsScreen(onImport: () async => false)),
|
||||
);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Import data'));
|
||||
await tester.pump();
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('import feedback replaces a still-visible export one', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SettingsScreen(
|
||||
onExport: () async {},
|
||||
onImport: () async => throw const FormatException('bad file'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Export data'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.bySemanticsLabel('Import data'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||
expect(find.byIcon(Icons.check_circle), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -4,17 +4,17 @@
|
||||
# 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
|
||||
mypy==2.4.0
|
||||
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
|
||||
pytest==9.1.1
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
ruff==0.17.2
|
||||
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
12
scripts/todo_hooks.sh
Executable 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 "$@"
|
||||
@ -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() {
|
||||
|
||||
@ -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
14
wake-alarm-strip.path
Normal 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
14
wake-alarm-strip.service
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
30
wake_alarm/_session_legs.py
Normal file
30
wake_alarm/_session_legs.py
Normal 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
|
||||
@ -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")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
247
wake_alarm/_session_strip.py
Normal file
247
wake_alarm/_session_strip.py
Normal 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())
|
||||
@ -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",
|
||||
|
||||
8
wake_alarm/tests/fixtures/session_legs.json
vendored
Normal file
8
wake_alarm/tests/fixtures/session_legs.json
vendored
Normal 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"}
|
||||
]
|
||||
}
|
||||
@ -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=[]),
|
||||
|
||||
30
wake_alarm/tests/test_session_legs.py
Normal file
30
wake_alarm/tests/test_session_legs.py
Normal 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"
|
||||
@ -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",
|
||||
|
||||
118
wake_alarm/tests/test_session_retry.py
Normal file
118
wake_alarm/tests/test_session_retry.py
Normal 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
|
||||
128
wake_alarm/tests/test_session_strip.py
Normal file
128
wake_alarm/tests/test_session_strip.py
Normal 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
|
||||
196
wake_alarm/tests/test_session_strip_run.py
Normal file
196
wake_alarm/tests/test_session_strip_run.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
103
wake_alarm/tests/test_strip_support.py
Normal file
103
wake_alarm/tests/test_strip_support.py
Normal 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))
|
||||
Loading…
Reference in New Issue
Block a user