Compare commits

...

33 Commits

Author SHA1 Message Date
9250db4cc1 E2E-encrypt the published alarm record
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / free-days-interop (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
The phone pushes alarm.json sealed under wake-alarm-sync/devices-e2e
(Argon2id key from the shared crdt-sync passphrase, keystore + lock
icon in Settings). The PC reads it through open_encrypted_client on
both Firebase and the GitHub mirror (now via GitHubSyncClient); no or
wrong passphrase reads as nothing published, never plaintext. The old
plaintext prefix is copied once per process, never deleted.

crdt-sync 0.11.0, crdt_sync_dart 0.14.1, crdt_sync_flutter 0.8.1,
sync_settings_ui 0.8.2, freedays 0.4.2, sickdays 0.5.3. install.sh
now fails if crdt_sync/pynacl/argon2 do not import.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:30:27 +01:00
9dbc4547ee Add deploy script and CLAUDE.md deploy line
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / free-days-interop (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
PC half: editable install + timers + trigger dry-run health check.
Phone: release APK via phone_deploy, health = installed base.apk
matches the built one. install.sh stays manual (interactive sudo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:33:04 +01:00
3b7f561452 Run the .fvmrc Flutter in repo scripts
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / free-days-interop (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The free-day hook ran the system flutter (3.47.9). On a fresh
checkout its pub get rewrote pubspec.lock to its own test_api, so
pre-commit failed with "files were modified". Hooks and
test_changed.sh now put the pinned fvm SDK on PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:10:57 +01:00
8e6fa90c80 Bump Flutter to 3.50.0 via material_ui
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / free-days-interop (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Flutter 3.50 deprecates package:flutter/material.dart, so the app
moves to material_ui 1.11.0 together with sync_settings_ui 0.8.0
and github_device_auth 0.5.0 (crdt_sync 0.13.1 rides along).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:06:16 +01:00
6cb51ad0f2 Bump sickdays to 0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:46:24 +01:00
4dbde2b525 Record dropped hardening residue items
Item 13, two-tag and locked-keyguard on-device runs dropped by kuhy.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWWcbzoov1KFZJSFcSs
2026-11-01 22:06:43 +01:00
18a304de55 Trim hardening TODO to real residue
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 22:02:50 +01:00
a51a67e2fe Remove done file-length TODO
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 22:02:11 +01:00
fd65c454e3 chore: bump deps to newest stable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 20:13:43 +01:00
05738db15a feat: JSON export/import on device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:55:04 +01:00
69be24d4c0 Session: tag catch-up + pre-deadline buzz
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:25:15 +01:00
1c2197a3cd deps: flutter 3.47.7 (workflows), gradle 9.8.2, coverage 7.17.0, ruff 0.17.2 (newest stable)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
2026-10-23 18:09:14 +02:00
2defe09e6a Launcher: label WakeAlarm, icon fitted to the shared 340px ink circle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kQ5PCKqBHAkAVkPSvp6dk
2026-10-23 14:36:09 +02:00
4b72899e5b Pin gatelock-v0.11.0 (Claim.grab_held + held_grabs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 11:47:24 +02:00
14b6dac605 deps: gatelock 0.9.0 -> 0.10.0 (pin only)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:49:51 +02:00
0060b48b10 deps: sickdays 0.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 13:53:14 +02:00
597901dc08 phone: sick-day interop both ways with keep; docs
check.sh now runs Dart->Python (keep, unsorted/duplicate input, a cleared
keep day, a keep-only edit that must not re-stamp state) and
Python->Dart (0.2.0 keep sets plus a 0.1.0 record with no keep, a stale
keep and a malformed one), Python 0.2.0 as the oracle with explicit
paths and HOME pointed at a scratch dir. DOCS-sick-days.md and the
SickDays.kt contract describe the alarm-only native cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:31:11 +02:00
ee7e380278 phone: per-locker sheet on the sick-day calendar
Tapping today or a later normal day opens a sheet with one switch per
locker (Workout lock, LeetCode, Book, Diet, Wake alarm), all off, and
"Mark sick"; a sick day reopens with its switches, "Save" (keep-only
edit, nothing written when unchanged) and "Clear sick day". Past days
stay inert. A sick day keeping any locker carries a dot in the calendar
and a "Kept: ..." line in the list. DayCell moves to day_cell.dart for
the 250-line cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:30:11 +02:00
7fe6108df3 phone: sick-day keep set (sickdays 0.2.0) in the Dart pool; alarm cache filtered
SickDayPool gains keep: mark/clear write state+actor+keep on one tick,
setKept re-stamps only keep+actor, read rules 1-5 in sick_day_keep.dart.
The native SickDays cache now holds only days on which the alarm stands
down, so a sick day that keeps the alarm rings like a normal day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:30:11 +02:00
5d0f06b19e PC: sick days stand the alarm down only when it is not kept
All PC enforcement (fallback ring, RTC self-wake, trigger, workday penalty
write/retract, audit) now asks sickdays.stands_down(LOCKER_ALARM, day) via
_sick.alarm_stands_down instead of is_sick_day, so a sick day that keeps the
alarm behaves exactly as a normal stick day. Bump sickdays pin to v0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:13:23 +02:00
199 changed files with 8334 additions and 2299 deletions

2
.fvmrc
View File

@ -1,3 +1,3 @@
{
"flutter": "3.47.7"
"flutter": "3.50.0"
}

View File

@ -71,7 +71,7 @@ jobs:
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
with:
flutter-version: 3.47.6
flutter-version: 3.50.0
working-directory: phone_app
- name: Dart writes, Python replays, both must agree

View File

@ -87,7 +87,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.6
flutter-version: 3.50.0
channel: stable
cache: true

View File

@ -8,6 +8,7 @@ default_language_version:
default_stages: [pre-commit]
fail_fast: false
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
@ -37,7 +38,7 @@ repos:
entry: scripts/check_repo_contract.sh
language: system
pass_filenames: false
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$
- id: no-noqa
name: Block noqa comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
@ -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.9.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.1.0#subdirectory=sickdays
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.11.0#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.2#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.5.3#subdirectory=sickdays
# Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
@ -186,6 +187,30 @@ repos:
language: system
files: \.(md|markdown)$
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: system
stages: [post-commit]
always_run: true
pass_filenames: false
- id: todo-import-pr-merge
name: import PR Todo sections (pull)
entry: scripts/todo_hooks.sh import-pr post-merge
language: system
stages: [post-merge]
always_run: true
pass_filenames: false
- id: todo-import-pr-rewrite
name: import PR Todo sections (pull --rebase)
entry: scripts/todo_hooks.sh import-pr post-rewrite
language: system
stages: [post-rewrite]
always_run: true
pass_filenames: false
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
# so the question is mechanically answerable at all.
#

View File

@ -100,9 +100,16 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
`_sync.py` is the only reader.
- Source of truth: a `crdt_sync` log written by `phone_app/` under
`wake-alarm-sync/devices/<device-id>/alarm.json`, at
`wake-alarm-sync/devices-e2e/<device-id>/alarm.json`, at
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. Same logical path on both
backends, so only the transport differs, never the parse.
- **End-to-end encrypted** (since 2026-11-12): fields are sealed with a key
derived from the shared passphrase (`~/.config/crdt-sync/password` on the
PC, the keystore + lock icon in Settings on the phone). Both readers go
through `open_encrypted_client`, which copies the old plaintext
`wake-alarm-sync/devices` over once per process and never deletes it. No
or wrong passphrase reads as "nothing published" (cache, then 08:00) —
never plaintext. diet_guard reads the same prefix for weigh-ins.
- **The Firebase reader discovers device directories; it is not pinned to
`devices/phone/`.** The phone's device id is migrating from the fixed
constant `phone` to a per-install uuid, and a pinned reader would silently
@ -128,9 +135,9 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
**404, not 403**.
- `crdt_sync` is imported at module scope but **guarded by
`try/except ImportError`**: the service runs `/usr/bin/python`, not a venv, so
a dependency that failed to install must degrade the read to the GitHub path
rather than crash the per-minute trigger. The GitHub path stays stdlib-only
for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
a dependency that failed to install must degrade the read to the cache
rather than crash the per-minute trigger. Both backends need it now (the
GitHub mirror is read through `GitHubSyncClient`, encrypted too). Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
dev venv.
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale
cache → 08:00. The cache bounds remote reads to ~288/day
@ -220,6 +227,7 @@ and the root ring unit's `systemctl start|stop` lines live there.
- lint: `pre-commit run ruff --all-files && cd phone_app && flutter analyze --fatal-infos --fatal-warnings`
- coverage: `python -m pytest -q wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-report=xml --cov-fail-under=100`
- coverage-gaps: `coverage-gaps coverage.xml`
- deploy: `git -C ~/src/wake-alarm pull --ff-only && ~/src/wake-alarm/scripts/deploy.sh` (editable install under /usr/bin/python3 + timers and `_trigger --dry-run` health check, then the release APK onto the Pixel via phone_deploy, health = installed base.apk equals the built one; `--build-only` for board sessions, `--check` for the health check alone; unit or root-file changes still need `bash install.sh`)
Other: full lint gate `pre-commit run --all-files`; watch live
`journalctl --user -fu wake-alarm-trigger.service`; `bash install.sh` /

View File

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

View File

@ -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.

View File

@ -6,7 +6,11 @@ day. Read its README for the rules and the RTDB wire format. This file covers
the phone half.
On a sick day the phone does not ring, the stick does not judge the day, and
nothing ring-related is recorded. It is **not** a free day: separate pool,
nothing ring-related is recorded -- **unless the day keeps the alarm**.
Since sickdays 0.2.0 a sick day carries a per-locker `keep` set (README
"keep (0.2.0)"): a kept locker behaves exactly as on a normal day, every other
one stands down. This app is the `alarm` locker (ring, PC fallback ring, RTC
self-wake, missed-alarm penalty / stick, morning routine). It is **not** a free day: separate pool,
separate RTDB node (`sickdays-sync/devices`), separate native cache, separate
screen, and every log line and event says `sick_day`.
@ -15,7 +19,8 @@ screen, and every log line and event says `sick_day`.
| | |
|---|---|
| Picker | `phone_app/lib/screens/sick_days_screen.dart` (+ `sick_days_body.dart`, the shared `day_calendar.dart`); the sick-face icon on the home screen |
| Rules, Dart port | `phone_app/lib/services/sick_day_pool.dart`: records `{state: sick/cleared, actor}`, one HLC per write, never a tombstone |
| Rules, Dart port | `phone_app/lib/services/sick_day_pool.dart`: records `{state: sick/cleared, actor, keep}`, one HLC per mark/clear, never a tombstone; keep read rules in `sick_day_keep.dart` |
| Per-locker sheet | `phone_app/lib/screens/sick_day_sheet.dart` (+ the dot in `day_cell.dart`) |
| Sync | `phone_app/lib/services/sick_day_sync.dart` (shared tick: `day_pool_sync.dart`), Firebase only |
| Ring path | `android/.../SickDays.kt` (+ `DayCache.kt`), read in `AlarmReceiver` through `RingDecision.kt` |
| Stick | `StickCheck.kt`: verdict `sick_day`; the streak skips sick days like free days |
@ -25,8 +30,16 @@ screen, and every log line and event says `sick_day`.
Same Monday-first grid as the free days, in `cat-2` blue (`DayMarkColors`
in `ui/theme.dart`) rather than the accent gold, under its own title and
icon. One tap marks a day sick, another clears it. **Today is tappable**
(unlike the free-day grid); the past is inert. No count, no confirmation.
icon. **Today is tappable** (unlike the free-day grid); the past is inert.
No count, no reason.
Tapping a normal day opens a bottom sheet: one switch per locker (Workout
lock, LeetCode, Book, Diet, Wake alarm), **all off** (off = stands down), and
"Mark sick". Tapping a sick day reopens it with the day's switches, "Save"
and "Clear sick day". Save is a keep-only edit: it re-stamps `keep` and
`actor`, never `state`, so it cannot re-assert "sick" over a concurrent
clear; an unchanged Save writes nothing. A sick day keeping any locker shows
a dot (in `on-fill` on the `cat-2` cell) and a `Kept: ...` line in the list.
## A date is never in both pools
@ -40,6 +53,15 @@ exactly as the Python README prescribes.
## The ring
**The native cache holds only the days on which the alarm stands down** --
sick and `alarm` not kept, past days included (`SickDaySync.publishToNative`
-> `SickDayPool.standDownIsoDates(SickLocker.alarm)`, the one place the keep
set is applied for Kotlin). A sick day keeping the alarm is simply absent, so
it rings, the stick judges it and the stakes card shows its stakes; Kotlin
never sees the keep set. The pre-alarm sync goes through the same publish.
The `Alarm cache:` line on the picker reads this cache back, so a kept-alarm
sick day is (correctly) not listed there.
`AlarmReceiver` asks `RingDecision.skip(free, sick)`. On a sick day it logs
`sick day: not ringing; re-armed for tomorrow`, appends `alarm_fired` with
`reason: "sick_day"`, re-arms and returns: no `AlarmService`, no
@ -94,12 +116,21 @@ sync runs between A and the session's end anyway.
`PoolSyncScheduleTest`, `StickCheckSickTest`. Run with
`cd phone_app/android && ./gradlew :app:testDebugUnitTest`, after one
`flutter build apk` has written `gradlew` and `local.properties`.
- Cross-language: `phone_app/scripts/sick_days_interop/check.sh` writes a
pool through Dart and reads it with `sickdays` (Python), failing on any
disagreement. Needs `SICKDAYS_PYTHON` or the sickdays venv.
- Keep: `test/services/sick_day_keep_test.dart` (wire, read rules 1-5, a
keep edit after a future PC tick, concurrent clear), `test/sick_day_sheet_test.dart`,
`test/sick_day_apply_test.dart`.
- Cross-language, both ways: `phone_app/scripts/sick_days_interop/check.sh`.
Dart writes (incl. keep and a keep-only edit) and Python 0.2.0 reads and
replays it; then Python writes (incl. a 0.1.0 record with no keep, a stale
keep and a malformed one) and Dart's verdicts are compared with Python's.
Needs `SICKDAYS_PYTHON` or the sickdays venv; Python runs with `HOME`
pointed at a scratch dir.
## Do NOT
- Don't fold sick days into `FreeDays`/`isFree`, or log them as `free_day`.
- Don't sync inside `AlarmReceiver`. Don't tombstone a date to clear it.
- Don't add a count, a reason prompt or a warning. There is no budget.
- Don't put a non-alarm-stand-down date in the native cache, and don't teach
Kotlin the keep set: filter in `publishToNative` only.
- Don't re-stamp `state` on a keep edit.

View File

@ -78,7 +78,7 @@ wake-alarm, todo) — narrower than the night list.
### Sick days (PC side, 2026-10-16)
A sick day is a date in the shared `sickdays` pool (`~/src/utils/sickdays`):
A sick day (that does not keep `alarm`, see below) is a date in the shared `sickdays` pool (`~/src/utils/sickdays`):
uncapped, no reason, today or later, and **not a free day** -- its own pool,
its own reason strings (`sick_day` / `sick day`). On a sick day `D` (the
alarm's day, so the RTC asks about *tomorrow* the evening before):
@ -109,12 +109,21 @@ has pulled from `A-5`. `--dry-run` / `--show` never pull (a pull rewrites
the pool file).
**Other readers of `workday_penalty.json`** (screen-locker, leetcode-guard)
must treat the file as void when **either** `issued_date` **or**
`penalty_date` is a sick day (`sickdays.is_sick_day(date.fromisoformat(...))`).
`issued_date` covers a miss at 08:15 marked sick at 14:00 -- the deletion
runs on the next RTC tick (<= 15 min) or the next morning's refresh, and a
reader must not depend on that; `penalty_date` is a sick day on which every
locker stands down anyway.
must treat the file as void when **either** its `issued_date` is a day the
*alarm* stands down on (`sickdays.stands_down(sickdays.LOCKER_ALARM,
date.fromisoformat(issued_date))`) **or** its `penalty_date` is a day *their
own* locker stands down on (`stands_down(LOCKER_WORKOUT, ...)` for
screen-locker, `LOCKER_LEETCODE` for leetcode-guard). `issued_date` covers a
miss at 08:15 marked sick at 14:00 -- the deletion runs on the next RTC tick
(<= 15 min) or the next morning's refresh, and a reader must not depend on
that; the ring day belongs to the alarm switch, the penalty day to whatever
the penalty charges.
**Keeping the alarm (`sickdays` 0.2.0).** A sick day may keep lockers
(`sickdays add --keep alarm`). Everything in the table above is the single
`alarm` switch (`_sick.alarm_stands_down`): on a sick day that keeps `alarm`
the fallback ring, RTC self-wake, trigger, workday penalty (written, and not
retracted) and audit behave exactly as on a normal stick day.
**Phone, assumed name:** the phone's `alarm_fired` on a sick day carries
`data.reason = "sick_day"` (by analogy with `free_day`); the audit treats
@ -122,7 +131,8 @@ it as not the morning's ring.
### Escape hatch (until 2026-10-15 inclusive)
Home screen button "That was a bug". Needs a typed reason ≥ 40 characters.
Bug-glyph button on the stakes card (spoken label "That was a bug"; the
app is text-free since 2026-11-10). Needs a typed reason ≥ 40 characters.
It lifts the phone lockdown, clears the streak, cancels today's PC penalty,
stops a ringing PC alarm, and writes everything into the incident report.
Unlimited uses — every use is listed at the top of that day's report.
@ -198,6 +208,9 @@ PC (`events.jsonl` `event`): `fallback_tick` (only on decision change),
next-alarm, headless `ring_started` on a heads-up ring, boot-path miss
(phone off before the alarm) → lockdown through tomorrow, escape hatch.
Not yet on the Pixel: the real focus-owner reaction and Firebase publish.
- Item 13 (black screen): dropped by kuhy 2026-11-12
- Two-tag dismiss on device: dropped by kuhy 2026-11-12
- Locked-keyguard dismiss on device: dropped by kuhy 2026-11-12
## Evidence for Claude

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@
# What it does:
# 1. Installs wake_alarm + dependencies for /usr/bin/python
# 2. Installs the systemd user units and enables their timers (trigger,
# session, fallback, rtc, audit)
# session, strip, fallback, rtc, audit)
# 3. Installs the root pieces of the fallback alarm: the ring WAVs, the
# ALSA ring script + system unit, the sudoers lines that let the user
# start/stop that unit, and (pc_wake.json "hibernate" mode only) the
@ -47,6 +47,8 @@ readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
readonly SESSION_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-session.service"
readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer"
readonly STRIP_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-strip.service"
readonly STRIP_PATH_SRC="$SCRIPT_DIR/wake-alarm-strip.path"
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
# Enforcement v2 user units (service + timer each).
readonly V2_UNITS=(wake-alarm-fallback wake-alarm-rtc wake-alarm-audit)
@ -130,11 +132,16 @@ echo "[1/4] Installing wake_alarm for /usr/bin/python..."
echo " Installed. Verifying import..."
/usr/bin/python3 -c "import wake_alarm; import gatelock"
echo " wake_alarm and gatelock import under /usr/bin/python."
# The alarm is end-to-end encrypted: without crdt_sync (pynacl, argon2-cffi)
# the trigger can only fall back to the cache and 08:00. Fail the install.
/usr/bin/python3 -c "import crdt_sync, nacl, argon2"
echo " crdt_sync (E2E) imports 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 +150,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

38
keep.md Normal file
View File

@ -0,0 +1,38 @@
# Text-free keep list
Every `textgate scan` hit that stays on purpose. `scripts/check_textgate_keep.sh`
runs the scan and fails on any hit no row below covers. Rework rationale and the
per-screen decisions: `~/data/wake-alarm_notext/keep.md` (2026-11-10 rework).
## Scan exceptions
First cell: a file, or a directory ending in `/`. Second cell: the exact text,
or `*` for any text. The checker parses only this table.
| Path | Text | Reason |
|---|---|---|
| `docs/explanations/` | * | Generated by the `explain-diff` skill (`docs/explanations/index.html`). Explanation pages for the repo, not app UI; a text-free explanation defeats its purpose. |
## Kept but not flagged by the scan
Rows copied from the rework's keep list. The scan does not report these today;
they are listed so the next rework does not delete them.
### OS-level labels: the app name only
| Where | Text | Reason |
|---|---|---|
| `phone_app/lib/ui/app_name.dart` `kAppName` | `WakeAlarm` | `MaterialApp.title`: the task-switcher name, a proper noun |
| `phone_app/android/app/src/main/AndroidManifest.xml` | `android:label="WakeAlarm"` | launcher label, a proper noun |
| `phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AppName.kt` `APP_NAME` | `WakeAlarm` | name of the ring and session notification channels |
### Content: data the program only displays
| Where | Content | Reason |
|---|---|---|
| `room_proof_screen.dart`, `session_tags_screen.dart` | registered NFC tag ids | authored identifiers read off the tag |
| `ring_challenge.dart` `challenge.display` | digits, operators, sequences, the reversed word | the thing you must read to dismiss; always visible |
| `home_location_screen.dart` | captured SSID, BSSIDs, coordinates | read from the radio |
| all screens | clock times, numeral dates, counts, kg, `N/M` | numbers |
| `escape_dialog.dart` | the excuse text kuhy types | typed value |
| `alarm_publisher.dart` two `publish()` failure strings | spoken labels, two distinct cue kinds | CLAUDE.md: keep both messages |

View File

@ -36,7 +36,7 @@
both apps are signed with the same release key. -->
<uses-permission android:name="com.kuhy.focus_owner.permission.SIGNAL_LOCKDOWN"/>
<application
android:label="wake_alarm_sync"
android:label="WakeAlarm"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@ -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">

View File

@ -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()
}

View File

@ -105,10 +105,9 @@ class AlarmService : Service() {
// IMPORTANCE_HIGH is required for a full-screen intent to be honoured.
val channel = NotificationChannel(
CHANNEL_ID,
"Wake alarm",
APP_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "The ringing wake alarm"
// The service owns sound and vibration, so the channel must not add
// its own — a channel sound would play on the notification stream
// and be silenced by DND, unlike STREAM_ALARM.
@ -129,8 +128,6 @@ class AlarmService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Wake alarm")
.setContentText("Tap to dismiss")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(Notification.CATEGORY_ALARM)
.setOngoing(true)

View File

@ -0,0 +1,9 @@
package com.kuhy.wake_alarm_sync
/**
* The app's only visible word, used as every notification channel's name.
*
* Matches `android:label` in AndroidManifest.xml and Dart's `kAppName`, so the
* notification settings name the app the way the launcher does.
*/
const val APP_NAME = "WakeAlarm"

View File

@ -15,7 +15,7 @@ import java.time.LocalDate
*
* Two jobs, one record. The launcher starts [MainActivity], and it has to
* know *before any Dart runs* whether to step aside into the session screen;
* the notification needs the leg text with no engine running behind it.
* the notification needs the leg glyph with no engine running behind it.
* Dart rewrites this cache on every leg change (`updateSessionNotice`) and
* `finishSession` clears it, so it is only ever as stale as the last change.
*
@ -50,8 +50,7 @@ object SessionNotice {
.apply()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Morning session", NotificationManager.IMPORTANCE_LOW)
.apply { description = "The live morning checklist" },
NotificationChannel(CHANNEL_ID, APP_NAME, NotificationManager.IMPORTANCE_LOW),
)
manager.notify(NOTIFICATION_ID, build(context, label, deadlineMillis))
}
@ -87,7 +86,6 @@ object SessionNotice {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = Notification.Builder(context, CHANNEL_ID)
.setContentTitle("Morning session")
.setContentText(label)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(Notification.CATEGORY_REMINDER)

View File

@ -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)
}
}

View File

@ -8,6 +8,15 @@ import java.time.LocalDate
* The native copy of the shared sick-day pool (`~/src/utils/sickdays`): the
* ISO dates on which the alarm must not ring and the stick stands down.
*
* Not every sick day: since sickdays 0.2.0 a sick day may *keep* some
* lockers active, and Dart writes here only the days on which the `alarm`
* locker stands down (sick, alarm not kept; `SickDayPool.standDownIsoDates`
* in `sick_day_pool.dart`). A sick day that keeps the alarm is absent, so
* it rings, judges the stick and shows its stakes like a normal day. Every
* reader of this cache ([RingDecision], [StickCheck], the stakes card via
* [EnforcementChannel]) therefore means "the alarm stands down", and none
* of them needs to know the keep set exists.
*
* A separate cache from [FreeDays], with its own prefs file, because the
* pools are separate things: a sick day is never called a free day, in a
* log line or anywhere else. Dart rewrites it after every mark, clear and

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -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))
}
}

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-all.zip

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="#211D1B"/>
<g fill="none" stroke="#B8862E" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,0)">
<g fill="none" stroke="#B8862E" stroke-width="57.8145" stroke-linecap="round" stroke-linejoin="round" transform="translate(-125.626,-125.626) scale(1.24536)">
<circle cx="512" cy="512" r="244"/>
<path d="M 512 512 L 512 350"/>
<path d="M 512 512 L 636 512"/>

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g fill="none" stroke="#B8862E" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,0)">
<g fill="none" stroke="#B8862E" stroke-width="57.8145" stroke-linecap="round" stroke-linejoin="round" transform="translate(-125.626,-125.626) scale(1.24536)">
<circle cx="512" cy="512" r="244"/>
<path d="M 512 512 L 512 350"/>
<path d="M 512 512 L 636 512"/>

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g fill="none" stroke="#FFFFFF" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,0)">
<g fill="none" stroke="#FFFFFF" stroke-width="57.8145" stroke-linecap="round" stroke-linejoin="round" transform="translate(-125.626,-125.626) scale(1.24536)">
<circle cx="512" cy="512" r="244"/>
<path d="M 512 512 L 512 350"/>
<path d="M 512 512 L 636 512"/>

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 379 B

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/home_screen.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
@ -7,6 +7,7 @@ import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/day_pool_refresh.dart';
import 'package:wake_alarm_sync/services/headless_ring_start.dart';
import 'package:wake_alarm_sync/ui/app_name.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// Route the Kotlin `AlarmActivity` hands to the engine as its initial
@ -63,7 +64,7 @@ class WakeAlarmSyncApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Wake Alarm Sync',
title: kAppName,
theme: buildAppTheme(),
navigatorKey: navigatorKey,
// AlarmActivity sets its initial route to kRingRoute, so the ring screen

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/probe/file_driven_scheduler.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';

View File

@ -1,7 +1,8 @@
// phone-slot: not-shared-storage (path is per-package: Android/data/<package>/files)
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_cell.dart';
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
export 'package:wake_alarm_sync/screens/day_cell.dart';
/// A month grid of one day pool (free days or sick days), one tap per day.
///
/// Adapted from punchme's `FreeDayCalendar` (Monday-first grid, dumb cells);
@ -26,9 +29,14 @@ class DayCalendar extends StatelessWidget {
this.keyPrefix = 'free-day',
this.markColor,
this.onMarkColor,
this.hasBadge,
super.key,
});
/// Whether a marked date carries a dot (a sick day keeping lockers);
/// null means never.
final bool Function(DateTime day)? hasBadge;
/// The month on display; only its year and month are read.
final DateTime month;
@ -66,15 +74,24 @@ class DayCalendar extends StatelessWidget {
/// Latest month the forward arrow reaches; null means no limit.
final DateTime? lastMonth;
static const _labels = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
static const _months = [
'January', 'February', 'March', 'April', 'May', 'June', 'July', //
'August', 'September', 'October', 'November', 'December',
];
bool _before(DateTime a, DateTime b) =>
a.year < b.year || (a.year == b.year && a.month < b.month);
Widget _weekdayDot(BuildContext context, int index) {
final theme = Theme.of(context);
final isWeekend = index == 5 || index == 6;
return Container(
width: isWeekend ? 8 : 6,
height: isWeekend ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isWeekend
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -89,22 +106,26 @@ class DayCalendar extends StatelessWidget {
Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
tooltip: 'Previous month',
icon: const Icon(
Icons.chevron_left,
semanticLabel: 'Previous month',
),
onPressed: canBack
? () => onMonth(DateTime(month.year, month.month - 1))
: null,
),
Expanded(
child: Text(
'${_months[month.month - 1]} ${month.year}',
'${month.month.toString().padLeft(2, '0')}/${month.year}',
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
tooltip: 'Next month',
icon: const Icon(
Icons.chevron_right,
semanticLabel: 'Next month',
),
onPressed: canForward
? () => onMonth(DateTime(month.year, month.month + 1))
: null,
@ -113,17 +134,8 @@ class DayCalendar extends StatelessWidget {
),
Row(
children: [
for (final label in _labels)
Expanded(
child: Center(
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
for (var i = 0; i < 7; i++)
Expanded(child: Center(child: _weekdayDot(context, i))),
],
),
const SizedBox(height: 4),
@ -150,6 +162,7 @@ class DayCalendar extends StatelessWidget {
key: ValueKey<String>('$keyPrefix-${FreeDayPool.toIso(date)}'),
day: day,
isMarked: isMarked(date),
badge: hasBadge?.call(date) ?? false,
isWorkday: isWorkday(date),
isPast: !open,
markColor: markColor ?? theme.colorScheme.primary,
@ -158,73 +171,3 @@ class DayCalendar extends StatelessWidget {
);
}
}
/// One day box: renders the state it is handed and reports taps.
class DayCell extends StatelessWidget {
/// Creates a cell for [day] of the month.
const DayCell({
required this.day,
required this.isMarked,
required this.isWorkday,
required this.isPast,
required this.markColor,
required this.onMarkColor,
required this.onTap,
super.key,
});
/// The day of the month, as shown in the box.
final int day;
/// Whether this date is marked in the pool.
final bool isMarked;
/// A marked day's fill.
final Color markColor;
/// Text on [markColor].
final Color onMarkColor;
/// Whether this date is a weekly workday (Tue/Wed/Thu), shaded.
final bool isWorkday;
/// Whether this date is read-only (past, or today on the free grid).
final bool isPast;
/// Called on tap, or null when the cell is inert.
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
// Past days keep their marker, faded: the record is history, not a
// choice still open.
final fade = isPast ? 0.38 : 1.0;
final background = isMarked
? markColor.withValues(alpha: fade)
: isWorkday
? colors.surfaceContainerHigh
: null;
final foreground = isMarked
? onMarkColor
: colors.onSurface.withValues(alpha: fade);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$day',
style: TextStyle(
color: foreground,
fontWeight: isMarked ? FontWeight.w600 : FontWeight.normal,
),
),
),
);
}
}

View File

@ -0,0 +1,88 @@
import 'package:material_ui/material_ui.dart';
Widget _dot(Color color) => Container(
key: const ValueKey<String>('day-badge'),
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
/// One day box: renders the state it is handed and reports taps.
class DayCell extends StatelessWidget {
/// Creates a cell for [day] of the month.
const DayCell({
required this.day,
required this.isMarked,
required this.isWorkday,
required this.isPast,
required this.markColor,
required this.onMarkColor,
required this.onTap,
this.badge = false,
super.key,
});
/// Whether a small dot sits in the corner, in [onMarkColor].
final bool badge;
/// The day of the month, as shown in the box.
final int day;
/// Whether this date is marked in the pool.
final bool isMarked;
/// A marked day's fill.
final Color markColor;
/// Text on [markColor].
final Color onMarkColor;
/// Whether this date is a weekly workday (Tue/Wed/Thu), shaded.
final bool isWorkday;
/// Whether this date is read-only (past, or today on the free grid).
final bool isPast;
/// Called on tap, or null when the cell is inert.
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
// Past days keep their marker, faded: the record is history, not a
// choice still open.
final fade = isPast ? 0.38 : 1.0;
final background = isMarked
? markColor.withValues(alpha: fade)
: isWorkday
? colors.surfaceContainerHigh
: null;
final foreground = isMarked
? onMarkColor
: colors.onSurface.withValues(alpha: fade);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(8),
),
child: Stack(
alignment: Alignment.center,
children: [
Text(
'$day',
style: TextStyle(
color: foreground,
fontWeight: isMarked ? FontWeight.w600 : FontWeight.normal,
),
),
if (badge) Positioned(top: 4, right: 4, child: _dot(foreground)),
],
),
),
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/weight_entry.dart';
/// Five chips, 1 to 5: the morning's energy, tapped next to the weigh-in.
@ -28,7 +28,12 @@ class EnergyPicker extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Energy', style: Theme.of(context).textTheme.labelLarge),
const Row(
children: <Widget>[
Icon(Icons.bolt, semanticLabel: 'Energy'),
SizedBox(width: 8),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/stakes.dart';
/// Asks why this morning should be excused; the reason, or null if
@ -34,50 +34,67 @@ class _EscapeDialogState extends State<EscapeDialog> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ready = _length >= escapeReasonMinChars;
const reasonWords = 'What went wrong?';
return AlertDialog(
// The keyboard covers half a small screen; scroll instead of overflow.
scrollable: true,
title: const Text('That was a bug'),
icon: const Icon(
Icons.bug_report_outlined,
semanticLabel: 'That was a bug',
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
'Lifts the app lockdown, clears the streak, cancels today’s PC '
'penalty and stops a ringing PC. Every use is listed in the '
'incident report.',
),
const SizedBox(height: 16),
TextField(
controller: _reason,
autofocus: true,
maxLines: 4,
minLines: 2,
decoration: const InputDecoration(
labelText: 'What went wrong?',
border: OutlineInputBorder(),
MergeSemantics(
child: Semantics(
label: reasonWords,
child: TextField(
controller: _reason,
autofocus: true,
maxLines: 4,
minLines: 2,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.edit_note),
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Text(
'$_length / $escapeReasonMinChars characters',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
Row(
children: <Widget>[
Expanded(
child: LinearProgressIndicator(
value: (_length / escapeReasonMinChars)
.clamp(0, 1)
.toDouble(),
),
),
const SizedBox(width: 8),
Text(
'$_length/$escapeReasonMinChars',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
actions: <Widget>[
TextButton(
IconButton(
key: const Key('escape-cancel'),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
FilledButton(
key: const Key('escape-confirm'),
onPressed: ready
? () => Navigator.of(context).pop(_reason.text.trim())
: null,
child: const Text('Excuse this morning'),
child: const Icon(Icons.check, semanticLabel: 'Excuse this morning'),
),
],
);

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// One upcoming free day, as the list shows it.
typedef UpcomingFreeDay = ({DateTime day, String reason});
@ -45,70 +46,78 @@ class FreeDaysBody extends StatelessWidget {
/// Called when the user gives a day back.
final void Function(DateTime day) onRelease;
static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
String _label(DateTime day) =>
'${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}';
String _toDdMmYyyy(DateTime day) {
final d = day.day.toString().padLeft(2, '0');
final m = day.month.toString().padLeft(2, '0');
return '$d.$m.${day.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final statusColors = theme.extension<AppStatusColors>()!;
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'$remaining of ${FreeDayPool.annualBudget}',
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
),
Row(
children: [
const Icon(Icons.event_available),
const SizedBox(width: 8),
Text(
'$remaining/${FreeDayPool.annualBudget}',
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
Text(
'free days left in $year',
'$year',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'On a free day nothing locks, nothing rings and nothing asks. '
'Every app, at once.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (status.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
status,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: status.contains('Error')
? theme.colorScheme.error
: statusColors.success,
),
),
StatusCue(semanticLabel: status),
],
const SizedBox(height: 16),
calendar,
const SizedBox(height: 24),
Text('Booked ahead', style: theme.textTheme.titleMedium),
Row(
children: [
Icon(
Icons.upcoming,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Booked ahead',
),
],
),
const SizedBox(height: 8),
if (upcoming.isEmpty)
Text(
'None. Tap a date above to book one.',
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
const SpokenLabel(
semanticLabel: 'None. Tap a date above to book one.',
child: DashedSlot(),
),
for (final entry in upcoming)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_label(entry.day)),
subtitle: entry.reason.isEmpty ? null : Text(entry.reason),
trailing: TextButton(
title: Text(_toDdMmYyyy(entry.day)),
subtitle: entry.reason.isEmpty
? null
: KeyedSubtree(
key: ValueKey<String>(
'content:reason-${FreeDayPool.toIso(entry.day)}',
),
child: Text(entry.reason),
),
trailing: IconButton(
key: ValueKey<String>(
'release-${FreeDayPool.toIso(entry.day)}',
),
icon: const Icon(Icons.event_busy, semanticLabel: 'Release'),
onPressed: busy ? null : () => onRelease(entry.day),
child: const Text('Release'),
),
),
],

View File

@ -1,12 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/screens/free_days_body.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/free_day_sync.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_sync.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// The one picker for the shared free-day pool.
///
@ -173,17 +174,20 @@ class _FreeDaysScreenState extends State<FreeDaysScreen> {
final pool = _pool;
return Scaffold(
appBar: AppBar(
title: const Text('Free days'),
// title removed
actions: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync now',
icon: const Icon(Icons.sync, semanticLabel: 'Sync now'),
onPressed: _busy ? null : () => unawaited(_sync()),
),
],
),
body: pool == null
? Center(child: Text(_status.isEmpty ? 'Loading…' : _status))
? Center(
child: _status.isEmpty
? const CircularProgressIndicator()
: StatusCue(semanticLabel: _status),
)
: FreeDaysBody(
year: _today.year,
remaining: pool.remaining(_today.year),

View File

@ -1,8 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:github_device_auth/github_device_auth.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/sync_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// The GitHub mirror screen: cutover-only sync transport, not a peer of
/// Firebase.
@ -86,62 +87,44 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
Widget build(BuildContext context) {
final isConnected = _settings.isConfigured;
return Scaffold(
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
appBar: AppBar(),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
isConnected
? 'Connected to GitHub.'
: 'Not connected to GitHub.',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Icon(
isConnected ? Icons.link : Icons.link_off,
color: isConnected
? Theme.of(context).colorScheme.tertiary
: Theme.of(context).colorScheme.onSurfaceVariant,
size: 32,
semanticLabel: isConnected
? 'Connected to GitHub.'
: 'Not connected to GitHub.',
),
const SizedBox(height: 12),
if (!isConnected)
ElevatedButton.icon(
ElevatedButton(
key: const ValueKey<String>('github-connect'),
onPressed: _busy ? null : _connectGitHub,
icon: const Icon(Icons.link),
label: const Text('Connect GitHub'),
child: const Icon(
Icons.link,
semanticLabel: 'Connect GitHub',
),
)
else
OutlinedButton.icon(
OutlinedButton(
key: const ValueKey<String>('github-disconnect'),
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.link_off),
label: const Text('Disconnect GitHub'),
),
const SizedBox(height: 16),
Text(
'PC config — the PC reads Firebase first and falls back '
'to GitHub. For the GitHub fallback, create:\n'
'~/.config/wake_alarm/sync.json\n\n'
'{ "token": "<same token>" }\n\n'
'The token needs "repo" scope so the PC can read '
'kuhyx/syncs.',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (_status.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
_status,
style: TextStyle(
color: _status.startsWith('Error')
? Theme.of(context).colorScheme.error
: null,
child: const Icon(
Icons.link_off,
semanticLabel: 'Disconnect GitHub',
),
),
],
const SizedBox(height: 16),
StatusCue(semanticLabel: _status),
],
),
),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
import 'package:material_ui/material_ui.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/cues.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The home screen's body: the armed time, the earlier-only control and the
@ -54,92 +54,101 @@ class HomeBody extends StatelessWidget {
/// Called after the escape hatch was used, so the screen publishes it.
final VoidCallback? onEscaped;
IconData? _statusIcon(String status) {
if (status.startsWith('Connect a sync backend')) return Icons.cloud_off;
if (status.startsWith('Sync store unavailable')) return Icons.sync_problem;
if (status.contains('✓')) return Icons.cloud_done;
return null;
}
@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: [
Icon(
Icons.alarm_on,
size: 32,
color: theme.colorScheme.primary,
semanticLabel: 'Alarm is armed for',
),
),
),
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),
if (isOverridden)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.restore,
size: 18,
semanticLabel:
'One-shot early start, back to $baseline '
'after it rings',
),
Text(baseline),
],
)
else
Icon(
Icons.all_inclusive,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Every day. It cannot be turned off.',
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: busy ? null : onPickEarlier,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Icon(
Icons.arrow_upward,
semanticLabel: 'Wake me earlier tomorrow',
),
),
),
),
],
],
const SizedBox(height: 24),
if (status.isNotEmpty) ...[
StatusCue(semanticLabel: status, icon: _statusIcon(status)),
const SizedBox(height: 24),
],
HomeTiles(
syncEpoch: syncEpoch,
busy: busy,
onEscaped: onEscaped,
),
if (!configured) ...[
const SizedBox(height: 16),
Icon(
Icons.cloud_off,
size: 28,
color: theme.extension<AppStatusColors>()!.warning,
semanticLabel: 'Open Settings to connect Firebase',
),
],
],
),
),
),
),
),

View File

@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.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/weight_entry.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// How many mornings the home screen shows.
const int kHistoryShown = 7;
@ -15,17 +18,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,24 +45,30 @@ 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();
final theme = Theme.of(context);
if (_sessions.isEmpty) {
final empty = widget.emptyText;
return _loaded && empty != null
? SpokenLabel(semanticLabel: empty, child: const DashedSlot())
: const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Last mornings', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (var i = 0; i < _sessions.length; i++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
describeMorning(_sessions[i], previousWeight: _previousWeight(i)),
style: theme.textTheme.bodyMedium,
padding: const EdgeInsets.symmetric(vertical: 4),
child: _MorningRow(
key: ValueKey<String>('history-${_sessions[i].date}'),
session: _sessions[i],
previousWeight: _previousWeight(i),
),
),
],
@ -70,6 +85,67 @@ class _HomeHistoryCardState extends State<HomeHistoryCard> {
}
}
/// One morning as glyphs and numbers; [describeMorning] is its spoken form.
class _MorningRow extends StatelessWidget {
const _MorningRow({
required this.session,
required this.previousWeight,
super.key,
});
final MorningSession session;
final double? previousWeight;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final status = theme.extension<AppStatusColors>();
final muted = theme.colorScheme.onSurfaceVariant;
final (IconData outcome, Color colour) = switch (session.outcome) {
SessionOutcome.completed => (
Icons.check_circle,
status?.success ?? Colors.green,
),
SessionOutcome.failed => (Icons.cancel, theme.colorScheme.error),
SessionOutcome.active => (Icons.more_horiz, muted),
};
final kg = session.weightKg;
final previous = previousWeight;
final energy = session.energy;
return SpokenLabel(
semanticLabel: describeMorning(session, previousWeight: previous),
// Wrap, not Row: a long weight delta must not overflow a narrow sheet.
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Text(session.date.substring(5)),
const SizedBox(width: 8),
Icon(outcome, color: colour, size: 20),
const SizedBox(width: 12),
Icon(Icons.replay, size: 16, color: muted),
Text('${session.reRings}'),
if (kg != null) ...<Widget>[
const SizedBox(width: 12),
Icon(Icons.monitor_weight_outlined, size: 16, color: muted),
Text('$kg'),
if (previous != null) Text(' (${_signed(kg - previous)})'),
],
if (energy != null) ...<Widget>[
const SizedBox(width: 12),
Icon(Icons.bolt, size: 16, color: muted),
Text('$energy/$maxEnergy'),
],
],
),
);
}
}
String _signed(double delta) {
final sign = delta > 0 ? '+' : (delta < 0 ? '−' : '±');
return '$sign${delta.abs().toStringAsFixed(1)}';
}
/// One line for [session]: `09-16 ✓ 0 re-rings · 72.4 kg (−0.3) · energy 3/5`.
String describeMorning(MorningSession session, {double? previousWeight}) {
final mark = switch (session.outcome) {
@ -83,9 +159,7 @@ String describeMorning(MorningSession session, {double? previousWeight}) {
if (kg != null) {
weight = ' · $kg kg';
if (previousWeight != null) {
final delta = kg - previousWeight;
final sign = delta > 0 ? '+' : (delta < 0 ? '−' : '±');
weight += ' ($sign${delta.abs().toStringAsFixed(1)})';
weight += ' (${_signed(kg - previousWeight)})';
}
}
final energy = session.energy == null

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/home_location_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// Registers the Wi-Fi and coordinate that mean "home".
///
@ -111,53 +113,95 @@ class _HomeLocationScreenState extends State<HomeLocationScreen> {
_ => 'Nothing readable here.',
};
Widget _valueRow(IconData icon, String semanticLabel, Widget value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(icon, semanticLabel: semanticLabel, size: 24),
const SizedBox(width: 12),
Expanded(child: value),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Home location')),
appBar: AppBar(),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Text(
'Away from home the two NFC tags are unreachable, so they are '
'skipped and the alarm asks the usual questions instead. The '
'walk is always required.',
style: theme.textTheme.bodyMedium,
_valueRow(
Icons.wifi,
'Home Wi-Fi',
KeyedSubtree(
key: const ValueKey<String>('content:home-ssid'),
child: _home.ssid != null
? Text(_home.ssid!)
: const DashedSlot(),
),
),
const SizedBox(height: 20),
Text('Home Wi-Fi', style: theme.textTheme.titleMedium),
Text(_home.ssid ?? 'not captured'),
const SizedBox(height: 12),
Text('Access points', style: theme.textTheme.titleMedium),
Text(_home.bssids.isEmpty ? 'none' : _home.bssids.join('\n')),
const SizedBox(height: 12),
Text('Coordinate', style: theme.textTheme.titleMedium),
Text(
_home.hasCoordinate
? '${_home.lat!.toStringAsFixed(5)}, '
_valueRow(
Icons.router,
'Access points',
KeyedSubtree(
key: const ValueKey<String>('content:home-bssids'),
child: _home.bssids.isEmpty
? const DashedSlot()
: Text(_home.bssids.join('\n')),
),
),
_valueRow(
Icons.place,
'Coordinate',
KeyedSubtree(
key: const ValueKey<String>('content:home-coord'),
child: _home.hasCoordinate
? Text(
'${_home.lat!.toStringAsFixed(5)}, '
'${_home.lon!.toStringAsFixed(5)} '
'(±${_home.radiusMeters.round()} m)'
: 'not captured',
'(±${_home.radiusMeters.round()} m)',
)
: const DashedSlot(),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _busy ? null : _capture,
child: const Text('Capture this place as home'),
),
const SizedBox(height: 8),
Text(
'Press once on each Wi-Fi band (2.4 GHz and 5 GHz) so a band '
'switch overnight is not mistaken for travelling.',
style: theme.textTheme.bodySmall,
Row(
children: <Widget>[
FilledButton(
onPressed: _busy ? null : _capture,
child: const Icon(
Icons.my_location,
semanticLabel: 'Capture this place as home',
),
),
const SizedBox(width: 12),
Icon(
Icons.network_wifi_2_bar,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Press once on each Wi-Fi band, 2.4 and 5 GHz',
),
const SizedBox(width: 8),
Icon(
Icons.network_wifi,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Press once on each Wi-Fi band, 2.4 and 5 GHz',
),
],
),
const SizedBox(height: 16),
TextButton(
onPressed: _home.isConfigured ? _clear : null,
child: const Text('Forget home'),
child: const Icon(
Icons.delete_outline,
semanticLabel: 'Forget home',
),
),
const SizedBox(height: 16),
Text(_status, style: theme.textTheme.bodyMedium),
StatusCue(semanticLabel: _status),
],
),
);

View File

@ -1,7 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/home_presence.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// One line saying what tonight's alarm will demand, and why.
///
@ -52,14 +53,26 @@ class _HomePresenceLineState extends State<HomePresenceLine> {
final verdict = _verdict;
if (verdict == null) return const SizedBox.shrink();
final atHome = verdict.state == PresenceState.home;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
atHome
child: SpokenLabel(
semanticLabel: atHome
? 'Tonight: tags required — ${verdict.detail}'
: 'Tonight: tags skipped — ${verdict.detail}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
atHome ? Icons.nfc : Icons.directions_walk,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Icon(atHome ? Icons.home : Icons.explore, size: 18),
],
),
),
);
}

View File

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/free_days_screen.dart';
import 'package:wake_alarm_sync/screens/home_body.dart';
@ -114,7 +114,7 @@ class _HomeScreenState extends State<HomeScreen> {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: _hour, minute: _minute),
helpText: 'Earlier than $current only',
helpText: '',
);
if (picked == null) return;
final accepted = await AlarmPolicy.setEarlier(picked.hour, picked.minute);
@ -171,14 +171,13 @@ class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Wake Alarm Sync'),
actions: [
IconButton(
icon: Icon(
Icons.event_available,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Free days',
),
tooltip: 'Free days',
onPressed: () => Navigator.push<void>(
context,
MaterialPageRoute<void>(builder: (_) => const FreeDaysScreen()),
@ -188,8 +187,8 @@ class _HomeScreenState extends State<HomeScreen> {
icon: Icon(
Icons.sick_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Sick days',
),
tooltip: 'Sick days',
onPressed: () => Navigator.push<void>(
context,
MaterialPageRoute<void>(builder: (_) => const SickDaysScreen()),
@ -200,11 +199,14 @@ class _HomeScreenState extends State<HomeScreen> {
Icons.settings,
// Dimmed relative to the adjacent title text (rule 28).
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Settings',
),
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.

View File

@ -0,0 +1,106 @@
import 'package:material_ui/material_ui.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,
semanticLabel: "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,
semanticLabel: '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.semanticLabel,
required this.sheet,
super.key,
});
final IconData icon;
final String semanticLabel;
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, size: 32, semanticLabel: semanticLabel),
const SizedBox(height: 8),
],
),
),
),
);
}
}

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/ring_challenge.dart';
import 'package:wake_alarm_sync/screens/ring_checklist.dart';
import 'package:wake_alarm_sync/screens/ring_stakes_line.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// The ring screen's body: the checklist, or the live challenge, plus status.
///
@ -38,7 +39,6 @@ class RingBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final inChallenges = gate.stage == DismissStage.challenges;
return PopScope(
// The alarm cannot be escaped with back; dismissing is the only way out.
@ -50,7 +50,6 @@ class RingBody extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('Wake up', style: theme.textTheme.headlineMedium),
const SizedBox(height: 16),
const RingStakesLine(),
const SizedBox(height: 8),
@ -65,7 +64,7 @@ class RingBody extends StatelessWidget {
else
RingChecklist(gate: gate),
const SizedBox(height: 16),
Text(status, style: theme.textTheme.bodyMedium),
_StatusRow(status: status),
],
),
),
@ -74,3 +73,21 @@ class RingBody extends StatelessWidget {
);
}
}
class _StatusRow extends StatelessWidget {
const _StatusRow({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final match = RegExp(r'\d+(/\d+)?').firstMatch(status);
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
StatusCue(semanticLabel: status),
if (match != null) ...[const SizedBox(width: 8), Text(match.group(0)!)],
],
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
/// The question half of the ring screen, shown once the walk is proven.
class RingChallenge extends StatelessWidget {
@ -32,37 +33,99 @@ class RingChallenge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
const answerWords = 'Answer';
return Column(
children: <Widget>[
Text(
'Question ${passed + 1} of $total',
style: theme.textTheme.labelLarge,
SpokenLabel(
semanticLabel: 'Question ${passed + 1} of $total',
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text('${passed + 1}/$total', style: theme.textTheme.labelLarge),
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(total, (i) {
final filled = i < passed;
final current = i == passed;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
filled
? Icons.circle
: current
? Icons.circle_outlined
: Icons.circle_outlined,
size: 8,
color: filled
? colorScheme.primary
: current
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
);
}),
),
],
),
),
const SizedBox(height: 8),
Text(challenge.hint, style: theme.textTheme.bodyLarge),
SpokenLabel(
semanticLabel: challenge.hint,
child: Icon(
_kindIcon(challenge.kind),
size: 40,
color: colorScheme.primary,
),
),
const SizedBox(height: 12),
Text(
// Always visible: a challenge that hides itself is unsolvable when
// the user reaches the screen having walked in from another room.
challenge.display,
textAlign: TextAlign.center,
style: theme.textTheme.displaySmall,
KeyedSubtree(
key: const ValueKey<String>('content:challenge'),
child: Text(
challenge.display,
textAlign: TextAlign.center,
style: theme.textTheme.displaySmall,
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Answer'),
onSubmitted: (_) => onSubmit(),
MergeSemantics(
child: Semantics(
label: answerWords,
child: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(prefixIcon: Icon(Icons.edit)),
onSubmitted: (_) => onSubmit(),
),
),
),
const SizedBox(height: 16),
FilledButton(onPressed: onSubmit, child: const Text('Submit')),
FilledButton(
key: const Key('ring-submit'),
style: FilledButton.styleFrom(minimumSize: const Size(160, 48)),
onPressed: onSubmit,
child: const Icon(Icons.check, semanticLabel: 'Submit'),
),
],
);
}
}
IconData _kindIcon(ChallengeKind kind) {
switch (kind) {
case ChallengeKind.math:
return Icons.calculate_outlined;
case ChallengeKind.sort:
return Icons.trending_up;
case ChallengeKind.sequence:
return Icons.more_horiz;
case ChallengeKind.reverse:
return Icons.swap_horiz;
}
}
/// Logs [challenge] so a hardware test can be scripted.
///
/// Debug aid only: the answer is already on screen for whoever is holding

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/services/room_proof.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
/// The three-step progress list shown while the room proof is incomplete.
///
@ -25,6 +27,7 @@ class RingChecklist extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
@ -32,42 +35,98 @@ class RingChecklist extends StatelessWidget {
if (!(gate.bypassActive && stage != DismissStage.steps))
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(
_isDone(stage)
? Icons.check_circle
: Icons.radio_button_unchecked,
color: _isDone(stage)
? theme.colorScheme.primary
: theme.disabledColor,
),
const SizedBox(width: 12),
Text(
label,
style: gate.stage == stage
? theme.textTheme.titleMedium
: theme.textTheme.bodyMedium,
),
],
child: SpokenLabel(
semanticLabel: label,
key: ValueKey<String>('ring-row-${stage.name}'),
child: Row(
children: <Widget>[
Icon(
_isDone(stage)
? Icons.check_circle
: Icons.radio_button_unchecked,
color: _isDone(stage)
? colorScheme.primary
: theme.disabledColor,
size: gate.stage == stage ? 32 : 24,
),
const SizedBox(width: 12),
_stageGlyphs(stage, gate.stage == stage, colorScheme),
],
),
),
),
if (gate.bypassActive)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
// This line persists; the status below it is overwritten by the
// live step count within seconds, so the reason the tags went
// away has to live here or the user never sees it.
gate.awayActive
child: SpokenLabel(
semanticLabel: gate.awayActive
? 'Away from home — the tags are elsewhere. Walk, then '
'answer ${gate.challengesRequired} questions.'
: 'Tags unavailable — walk, then answer '
'${gate.challengesRequired} questions instead.',
style: theme.textTheme.bodyMedium,
key: const Key('ring-bypass'),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
gate.awayActive ? Icons.explore : Icons.block,
color: colorScheme.error,
),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.directions_walk),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.quiz_outlined),
Text('${gate.challengesRequired}'),
],
),
),
),
],
);
}
Widget _stageGlyphs(DismissStage stage, bool current, ColorScheme cs) {
final primary = cs.primary;
final muted = cs.onSurfaceVariant;
final color = current ? primary : muted;
final size = current ? 32.0 : 24.0;
switch (stage) {
case DismissStage.tag1:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.nfc, size: size, color: color),
Text(
'1',
style: TextStyle(fontSize: size, color: color),
),
],
);
case DismissStage.steps:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.directions_walk, size: size, color: color),
Text(
'${RoomProof.requiredSteps}',
style: TextStyle(fontSize: size, color: color),
),
],
);
case DismissStage.tag2:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.nfc, size: size, color: color),
Text(
'2',
style: TextStyle(fontSize: size, color: color),
),
],
);
case DismissStage.challenges:
case DismissStage.done:
return const SizedBox.shrink();
}
}
}

View File

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/ring_body.dart';
import 'package:wake_alarm_sync/screens/ring_challenge.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/enforcement_bridge.dart';
import 'package:wake_alarm_sync/services/today_stakes.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The ring screen's one line: what not dismissing costs today.
@ -23,7 +25,7 @@ class RingStakesLine extends StatefulWidget {
}
class _RingStakesLineState extends State<RingStakesLine> {
String? _line;
({DateTime by, DateTime lockThrough})? _stakes;
@override
void initState() {
@ -32,32 +34,53 @@ class _RingStakesLineState extends State<RingStakesLine> {
}
Future<void> _load() async {
String? line;
({DateTime by, DateTime lockThrough})? stakes;
try {
final state = await (widget.bridge ?? EnforcementBridge()).state();
line = state == null ? null : ringStakesLine(state);
stakes = state == null ? null : ringStakes(state);
} on Exception {
line = null;
stakes = null;
}
if (!mounted || line == null) return;
setState(() => _line = line);
if (!mounted || stakes == null) return;
setState(() => _stakes = stakes);
}
@override
Widget build(BuildContext context) {
final line = _line;
if (line == null) return const SizedBox.shrink();
final stakes = _stakes;
if (stakes == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final statusColors = theme.extension<AppStatusColors>();
final warningColor = statusColors?.warning ?? Colors.amber;
final line =
'Stick day: no dismiss by ${hhmm(stakes.by)} → apps locked through '
'${formatDay(stakes.lockThrough)}';
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
line,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
// Null-safe: a missing theme extension must not break a ring.
color: theme.extension<AppStatusColors>()?.warning,
child: SpokenLabel(
semanticLabel: line,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.gavel_outlined, color: warningColor),
const SizedBox(width: 4),
Text(hhmm(stakes.by), style: TextStyle(color: warningColor)),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward, size: 16),
const SizedBox(width: 4),
Icon(Icons.lock_outline, color: warningColor),
const SizedBox(width: 4),
Text(
_formatLockDate(stakes.lockThrough),
style: TextStyle(color: warningColor),
),
],
),
),
);
}
String _formatLockDate(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}'
'.${d.month.toString().padLeft(2, '0')}';
}

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// Registers the two NFC tags the dismiss sequence walks between.
///
@ -43,7 +45,17 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
Future<void> _load() async {
final settings = await RoomProofSettings.load();
if (!mounted) return;
setState(() => _settings = settings);
var status = '';
for (var i = 0; i < RoomProofSettings.requiredTagCount; i++) {
if (settings.tagAt(i) != null) {
status = 'Tag ${i + 1} registered.';
break;
}
}
setState(() {
_settings = settings;
_status = status;
});
}
Future<void> _startScan(int slot) async {
@ -67,7 +79,8 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
if (!mounted) return;
setState(() {
_scanningSlot = null;
_status = 'That is already tag ${slot == 0 ? 2 : 1}. Use a different '
_status =
'That is already tag ${slot == 0 ? 2 : 1}. Use a different '
'tag, in a different room.';
});
return;
@ -88,18 +101,33 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
final scanning = _scanningSlot == slot;
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
children: <Widget>[
Text('Tag ${slot + 1}: ${id ?? "none registered"}'),
const SizedBox(height: 8),
CircleAvatar(radius: 14, child: Text('${slot + 1}')),
const SizedBox(width: 12),
Expanded(
child: id != null
? KeyedSubtree(
key: ValueKey<String>('content:tag-$slot'),
child: Text(id),
)
: const DashedSlot(),
),
const SizedBox(width: 12),
FilledButton(
key: ValueKey<String>('register-$slot'),
onPressed: _scanningSlot != null ? null : () => _startScan(slot),
child: Text(
scanning
? 'Waiting for tag…'
: 'Register tag ${slot + 1}${id == null ? "" : " again"}',
),
child: scanning
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
id == null ? Icons.add : Icons.refresh,
semanticLabel: id == null
? 'Register tag ${slot + 1}'
: 'Register tag ${slot + 1} again',
),
),
],
),
@ -109,20 +137,15 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Room proof')),
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
'Put the two tags in different rooms. Dismissing the alarm '
'means tapping tag 1, walking, then tapping tag 2.',
),
const SizedBox(height: 24),
for (var i = 0; i < RoomProofSettings.requiredTagCount; i++)
_slotRow(i),
Text(_status),
StatusCue(semanticLabel: _status),
],
),
),

View File

@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/energy_picker.dart';
import 'package:wake_alarm_sync/screens/session_checklist.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
const _weightSemantics = 'Weight (kg)';
/// The session screen's body: the checklist, the check-in, the clocks.
///
@ -49,8 +53,9 @@ class SessionBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final leg = session.currentLeg;
final reRingWords =
'Re-rings: ${session.reRings}/${MorningSession.maxReRings}';
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
@ -58,58 +63,54 @@ class SessionBody extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Stay up', style: theme.textTheme.headlineMedium),
const SizedBox(height: 4),
Text(
'The alarm is off. It comes back if a step below is missed. '
'Leaving the app is fine — the notification brings you back.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
SessionChecklist(session: session, now: now),
const SizedBox(height: 24),
if (_awaitingWeight) ...<Widget>[
EnergyPicker(selected: energy, onSelect: onSelectEnergy),
const SizedBox(height: 12),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
MergeSemantics(
child: Semantics(
label: _weightSemantics,
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.monitor_weight_outlined),
border: OutlineInputBorder(),
),
onSubmitted: (_) => onSubmitWeight(),
),
),
decoration: const InputDecoration(
labelText: 'Weight (kg)',
border: OutlineInputBorder(),
),
onSubmitted: (_) => onSubmitWeight(),
),
const SizedBox(height: 12),
FilledButton(
key: const Key('session-log-weight'),
onPressed: onSubmitWeight,
child: const Text('Log weight'),
child: const Icon(Icons.check, semanticLabel: 'Log weight'),
),
const SizedBox(height: 16),
],
if (leg != null)
Text(
_deadlineLine(leg),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
if (leg != null) ...<Widget>[
SpokenLabel(
semanticLabel: _deadlineWords(leg),
child: _DeadlineRow(leg: leg, session: session, now: now),
),
const SizedBox(height: 8),
],
SpokenLabel(
semanticLabel: reRingWords,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(Icons.replay, size: 16),
Text('${session.reRings}/${MorningSession.maxReRings}'),
],
),
const SizedBox(height: 8),
Text(
'Re-rings: ${session.reRings}/${MorningSession.maxReRings}',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 16),
if (status.isNotEmpty)
Text(
status,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge,
),
Center(child: StatusCue(semanticLabel: status)),
],
),
),
@ -117,10 +118,10 @@ class SessionBody extends StatelessWidget {
);
}
String _deadlineLine(SessionLeg leg) {
String _deadlineWords(SessionLeg leg) {
final soonest = session.earliest(leg);
if (soonest != null && now.isBefore(soonest)) {
return 'Shower — tag counts in ${_mmss(soonest.difference(now))}';
return 'Shower tag counts in ${_mmss(soonest.difference(now))}';
}
final deadline = session.deadline(leg);
if (now.isBefore(deadline)) {
@ -135,3 +136,54 @@ class SessionBody extends StatelessWidget {
return '$m:$s';
}
}
class _DeadlineRow extends StatelessWidget {
const _DeadlineRow({
required this.leg,
required this.session,
required this.now,
});
final SessionLeg leg;
final MorningSession session;
final DateTime now;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final soonest = session.earliest(leg);
if (soonest != null && now.isBefore(soonest)) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(Icons.shower),
const Icon(Icons.hourglass_top),
Text(_mmss(soonest.difference(now))),
],
);
}
final deadline = session.deadline(leg);
if (now.isBefore(deadline)) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(Icons.alarm),
Text(_mmss(deadline.difference(now))),
],
);
}
final statusColors = theme.extension<AppStatusColors>();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.alarm_off, color: statusColors?.warning ?? Colors.amber),
],
);
}
static String _mmss(Duration d) {
final m = d.inMinutes.toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/weight_entry.dart';

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/weight_entry.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The session's progress list: the weigh-in, then every leg with a tag.
///
@ -23,50 +25,142 @@ class SessionChecklist extends StatelessWidget {
final theme = Theme.of(context);
final weighed = session.weightKg != null || session.pendingWeightKg != null;
final current = session.currentLeg;
final rows = <(String, bool, bool)>[
(
'Weigh in${_kg(session)}',
weighed,
!weighed && current == SessionLeg.bathroomIn,
final rows = <Widget>[
_WeighInRow(
session: session,
weighed: weighed,
current: current,
theme: theme,
),
for (final leg in SessionLeg.values)
if (session.passedHow[leg] != LegPass.skipped)
(
_label(leg),
session.passedAt.containsKey(leg),
leg == current && (leg != SessionLeg.bathroomIn || weighed),
_LegRow(
session: session,
leg: leg,
current: current,
theme: theme,
weighed: weighed,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (final (label, done, live) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done ? theme.colorScheme.primary : theme.disabledColor,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: live
? theme.textTheme.titleMedium
: theme.textTheme.bodyMedium,
),
),
],
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: rows);
}
}
class _WeighInRow extends StatelessWidget {
const _WeighInRow({
required this.session,
required this.weighed,
required this.current,
required this.theme,
});
final MorningSession session;
final bool weighed;
final SessionLeg? current;
final ThemeData theme;
@override
Widget build(BuildContext context) {
final live = !weighed && current == SessionLeg.bathroomIn;
return SpokenLabel(
semanticLabel: weighed
? session.energy == null
? 'Weight logged'
: 'Weigh in — ${session.weightKg ?? session.pendingWeightKg} kg · energy ${session.energy}/$maxEnergy'
: 'Weigh in',
key: const ValueKey<String>('session-row-weigh'),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(
weighed ? Icons.check_circle : Icons.radio_button_unchecked,
color: weighed ? theme.colorScheme.primary : theme.disabledColor,
),
),
],
const SizedBox(width: 12),
Icon(
Icons.monitor_weight_outlined,
size: 32,
color: live ? theme.colorScheme.primary : null,
),
const SizedBox(width: 12),
if (weighed) ...<Widget>[
Text('${session.weightKg ?? session.pendingWeightKg}'),
if (session.energy != null) ...<Widget>[
const SizedBox(width: 8),
const Icon(Icons.bolt, size: 18),
Text('${session.energy}/$maxEnergy'),
],
],
],
),
),
);
}
}
class _LegRow extends StatelessWidget {
const _LegRow({
required this.session,
required this.leg,
required this.current,
required this.theme,
required this.weighed,
});
final MorningSession session;
final SessionLeg leg;
final SessionLeg? current;
final ThemeData theme;
final bool weighed;
@override
Widget build(BuildContext context) {
final done = session.passedAt.containsKey(leg);
final how = session.passedHow[leg];
final live = leg == current && (leg != SessionLeg.bathroomIn || weighed);
final bypassed = how == LegPass.bypassed;
final statusColors = theme.extension<AppStatusColors>();
final warningColor = statusColors?.warning ?? Colors.amber;
IconData frontIcon;
if (done) {
frontIcon = bypassed ? Icons.timer_off : Icons.check_circle;
} else {
frontIcon = Icons.radio_button_unchecked;
}
Color frontColor;
if (done) {
frontColor = bypassed ? warningColor : theme.colorScheme.primary;
} else {
frontColor = theme.disabledColor;
}
return SpokenLabel(
semanticLabel: how == null ? leg.label : _semanticLabel(leg, how),
key: ValueKey<String>('session-row-${leg.name}'),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(frontIcon, color: frontColor, size: live ? 32 : 24),
const SizedBox(width: 12),
_legIcon(
leg,
size: live ? 32 : 24,
color: live ? theme.colorScheme.primary : null,
),
const SizedBox(width: 12),
const Icon(Icons.nfc, size: 18),
],
),
),
);
}
String _label(SessionLeg leg) {
final how = session.passedHow[leg];
String _semanticLabel(SessionLeg leg, LegPass how) {
if (how == LegPass.bypassed) return '${leg.label} (skipped by the clock)';
if (leg == SessionLeg.bathroomIn && !session.bathroomTagRequired) {
return 'Weight logged';
@ -74,12 +168,20 @@ class SessionChecklist extends StatelessWidget {
return leg.label;
}
static String _kg(MorningSession session) {
final kg = session.weightKg ?? session.pendingWeightKg;
if (kg == null) return '';
final energy = session.energy;
return energy == null
? ' — $kg kg'
: ' — $kg kg · energy $energy/$maxEnergy';
Widget _legIcon(SessionLeg leg, {double size = 24, Color? color}) {
return Icon(_legIconData(leg), size: size, color: color);
}
IconData _legIconData(SessionLeg leg) {
switch (leg) {
case SessionLeg.bathroomIn:
return Icons.bathroom_outlined;
case SessionLeg.showerDone:
return Icons.shower;
case SessionLeg.dressed:
return Icons.checkroom;
case SessionLeg.atDesk:
return Icons.desk;
}
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/screens/session_body.dart';
import 'package:wake_alarm_sync/screens/session_checkin.dart';

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// Registers the three NFC tags the morning session walks through.
///
@ -81,52 +83,67 @@ class _SessionTagsScreenState extends State<SessionTagsScreen> {
});
}
IconData _iconForSlot(SessionTagSlot slot) => switch (slot) {
SessionTagSlot.bathroom => Icons.shower,
SessionTagSlot.wardrobe => Icons.checkroom,
SessionTagSlot.desk => Icons.desk,
};
String _semanticLabelForSlot(SessionTagSlot slot) => switch (slot) {
SessionTagSlot.bathroom => 'Bathroom (scale + shower)',
SessionTagSlot.wardrobe => 'Wardrobe',
SessionTagSlot.desk => 'Desk',
};
Widget _slotRow(SessionTagSlot slot) {
final id = _settings.tagAt(slot);
final scanning = _scanning == slot;
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
children: <Widget>[
Text('${_title(slot)}: ${id ?? "none registered"}'),
const SizedBox(height: 8),
Icon(_iconForSlot(slot), semanticLabel: _semanticLabelForSlot(slot)),
const SizedBox(width: 12),
Expanded(
child: id != null
? KeyedSubtree(
key: ValueKey<String>('content:tag-${slot.name}'),
child: Text(id),
)
: const DashedSlot(),
),
const SizedBox(width: 12),
FilledButton(
key: ValueKey<String>('register-${slot.name}'),
onPressed: _scanning != null ? null : () => _startScan(slot),
child: Text(
scanning
? 'Waiting for tag…'
: 'Register ${slot.name} tag${id == null ? "" : " again"}',
),
child: scanning
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
id == null ? Icons.add : Icons.refresh,
semanticLabel: id == null
? 'Register ${slot.name} tag'
: 'Register ${slot.name} tag again',
),
),
],
),
);
}
static String _title(SessionTagSlot slot) => switch (slot) {
SessionTagSlot.bathroom => 'Bathroom (scale + shower)',
SessionTagSlot.wardrobe => 'Wardrobe',
SessionTagSlot.desk => 'Desk',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Morning session tags')),
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
'After the alarm is dismissed: weigh in and tap the bathroom '
'tag, shower and tap it again, tap the wardrobe tag dressed, '
'then the desk tag. A slot left empty is skipped.',
),
const SizedBox(height: 24),
for (final slot in SessionTagSlot.values) _slotRow(slot),
Text(_status),
StatusCue(semanticLabel: _status),
],
),
),

View File

@ -1,12 +1,20 @@
import 'package:flutter/material.dart';
import 'dart:developer' as developer;
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show E2ePassphraseButton;
import 'package:material_ui/material_ui.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart';
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_e2e.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';
import 'package:wake_alarm_sync/ui/theme.dart';
/// Settings screen: links to the two sync surfaces.
///
@ -19,19 +27,98 @@ 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) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final statusColors = theme.extension<AppStatusColors>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
appBar: AppBar(
actions: [
// End-to-end encryption passphrase: a closed lock while the last
// sync found no usable key, so nothing reaches the PC until set.
ValueListenableBuilder<bool>(
valueListenable: alarmSyncLocked,
builder: (_, locked, _) => E2ePassphraseButton(
key: const Key('settings-e2e'),
locked: locked,
),
),
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: [
ListTile(
key: const Key('settings-sync'),
contentPadding: EdgeInsets.zero,
title: const Text('Sync settings'),
subtitle: const Text('Firebase sync'),
leading: const Icon(
Icons.cloud_sync_outlined,
semanticLabel: 'Sync settings',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(
@ -48,37 +135,57 @@ class SettingsScreen extends StatelessWidget {
),
),
ListTile(
key: const Key('settings-room-proof'),
contentPadding: EdgeInsets.zero,
title: const Text('Room proof'),
subtitle: const Text('Two NFC tags, at two locations'),
leading: const Icon(Icons.nfc, semanticLabel: 'Room proof'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const RoomProofScreen()),
),
),
ListTile(
key: const Key('settings-session-tags'),
contentPadding: EdgeInsets.zero,
title: const Text('Morning session tags'),
subtitle: const Text('Bathroom, wardrobe, desk — after the ring'),
leading: const Icon(
Icons.checklist,
semanticLabel: 'Morning session tags',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const SessionTagsScreen()),
),
),
ListTile(
key: const Key('settings-home'),
contentPadding: EdgeInsets.zero,
title: const Text('Home location'),
subtitle: const Text('The Wi-Fi and place that mean you are home'),
leading: const Icon(
Icons.home_outlined,
semanticLabel: 'Home location',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const HomeLocationScreen()),
),
),
ListTile(
key: const Key('settings-github'),
contentPadding: EdgeInsets.zero,
title: const Text('Advanced sync (GitHub)'),
subtitle: const Text('Cutover mirror — not recommended'),
trailing: const Icon(Icons.chevron_right),
leading: Icon(
Icons.code,
color: colors.onSurfaceVariant.withValues(alpha: 0.6),
semanticLabel: 'Advanced sync (GitHub)',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: statusColors?.warning ?? colors.error,
semanticLabel: 'Cutover mirror, not recommended',
),
const Icon(Icons.chevron_right),
],
),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const GitHubMirrorScreen()),
),

View File

@ -0,0 +1,197 @@
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
export 'package:wake_alarm_sync/screens/sick_day_sheet.dart';
/// What each locker is called on screen, in `sickdays.LOCKERS` order.
const sickLockerLabels = <String, String>{
SickLocker.workout: 'Workout lock',
SickLocker.leetcode: 'LeetCode',
SickLocker.book: 'Book',
SickLocker.diet: 'Diet',
SickLocker.alarm: 'Wake alarm',
};
/// Icons for each locker, matching [sickLockerLabels] order.
const sickLockerIcons = <String, IconData>{
SickLocker.workout: Icons.fitness_center,
SickLocker.leetcode: Icons.code,
SickLocker.book: Icons.menu_book,
SickLocker.diet: Icons.restaurant,
SickLocker.alarm: Icons.alarm,
};
/// The kept lockers of a day as one line, in [sickLockerLabels] order.
String keptLabel(Set<String> kept) => [
for (final MapEntry(:key, :value) in sickLockerLabels.entries)
if (kept.contains(key)) value,
].join(', ');
/// What the sheet was closed with.
enum SickSheetAction {
/// Mark a normal day sick, keeping the switched-on lockers.
mark,
/// Replace a sick day's kept lockers (a keep-only edit).
save,
/// Clear a sick day back to normal.
clear,
}
/// The sheet's answer: an action and the switches as they stood.
typedef SickSheetResult = ({SickSheetAction action, Set<String> keep});
/// Why [day] cannot be marked sick for the free-day pool, or null.
///
/// An unopenable free-day pool ([free] null) refuses: "could not check"
/// must never count as "not a free day".
String? freeDayRefusal(FreeDayPool? free, DateTime day) {
if (free == null) {
return 'Free-day pool unavailable — cannot rule out a free day on '
'${FreeDayPool.toIso(day)}.';
}
if (!free.isFree(day)) return null;
return '${FreeDayPool.toIso(day)} is a free day — release it in Free '
'days first.';
}
/// Applies [result] to [day] in [pool]; true when anything was written.
///
/// A Save that changes nothing writes nothing: a fresh tick on an unchanged
/// keep set would only churn the sync. A mark re-checks [free] through the
/// pool (null refuses). Throws what the pool throws.
Future<bool> applySickSheet(
SickDayPool pool,
DateTime day,
SickSheetResult result, {
required FreeDayPool? free,
required DateTime now,
}) async {
switch (result.action) {
case SickSheetAction.mark:
final refusal = freeDayRefusal(free, day);
if (refusal != null) throw SickDayPolicyError(refusal);
await pool.add(day, isFree: free!.isFree, now: now, keep: result.keep);
case SickSheetAction.save:
if (setEquals(result.keep, pool.keptLockers(day))) return false;
await pool.setKept(day, result.keep, now: now);
case SickSheetAction.clear:
await pool.remove(day, now: now);
}
return true;
}
/// Opens [SickDaySheet] for [day]; null when dismissed.
Future<SickSheetResult?> showSickDaySheet(
BuildContext context, {
required DateTime day,
required bool isSick,
required Set<String> kept,
}) => showModalBottomSheet<SickSheetResult>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => SickDaySheet(day: day, isSick: isSick, kept: kept),
);
/// One switch per locker for one day: on = the locker still applies,
/// off = it stands down. A normal day opens with every switch off and a
/// "Mark sick" confirm; a sick day opens with its current switches, "Save"
/// (a keep-only edit) and "Clear sick day".
class SickDaySheet extends StatefulWidget {
/// Creates the sheet for [day].
const SickDaySheet({
required this.day,
required this.isSick,
required this.kept,
super.key,
});
/// The date being marked or edited.
final DateTime day;
/// Whether [day] is already sick (edit) rather than normal (mark).
final bool isSick;
/// The switches to start from.
final Set<String> kept;
@override
State<SickDaySheet> createState() => _SickDaySheetState();
}
class _SickDaySheetState extends State<SickDaySheet> {
late final Set<String> _keep = {...widget.kept};
String _toDdMmYyyy(DateTime day) {
final d = day.day.toString().padLeft(2, '0');
final m = day.month.toString().padLeft(2, '0');
return '$d.$m.${day.year}';
}
void _close(SickSheetAction action) => Navigator.of(
context,
).pop<SickSheetResult>((action: action, keep: {..._keep}));
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final day = widget.day;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.sick_outlined),
const SizedBox(width: 8),
Text(_toDdMmYyyy(day), style: theme.textTheme.titleMedium),
],
),
const SizedBox(height: 8),
for (final MapEntry(:key, :value) in sickLockerLabels.entries)
SwitchListTile(
key: ValueKey<String>('keep-$key'),
contentPadding: EdgeInsets.zero,
title: Icon(sickLockerIcons[key], semanticLabel: value),
value: _keep.contains(key),
onChanged: (on) =>
setState(() => on ? _keep.add(key) : _keep.remove(key)),
),
const SizedBox(height: 16),
if (widget.isSick) ...[
FilledButton(
key: const ValueKey<String>('sick-save'),
onPressed: () => _close(SickSheetAction.save),
child: const Icon(Icons.check, semanticLabel: 'Save'),
),
const SizedBox(height: 8),
TextButton(
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
key: const ValueKey<String>('sick-clear'),
onPressed: () => _close(SickSheetAction.clear),
child: const Icon(
Icons.delete_outline,
semanticLabel: 'Clear sick day',
),
),
] else
FilledButton(
key: const ValueKey<String>('sick-mark'),
onPressed: () => _close(SickSheetAction.mark),
child: const Icon(Icons.sick, semanticLabel: 'Mark sick'),
),
],
),
),
);
}
}

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'package:wake_alarm_sync/screens/sick_day_sheet.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// The sick-day picker's body: what a sick day does, the status line, the
/// month calendar and the sick days still ahead.
@ -15,11 +17,16 @@ class SickDaysBody extends StatelessWidget {
required this.upcoming,
required this.status,
required this.cache,
required this.cacheRead,
required this.refused,
required this.calendar,
required this.keptOf,
super.key,
});
/// The lockers a sick day keeps active, for its line in the list.
final Set<String> Function(DateTime day) keptOf;
/// The month grid, built by the screen, which owns the displayed month.
final DayCalendar calendar;
@ -29,64 +36,112 @@ class SickDaysBody extends StatelessWidget {
/// The status line; may be a raw `Error: …`.
final String status;
/// The native ring-path cache, read back (`Alarm cache: …`).
final String cache;
/// The native ring-path cache, read back (list of ISO
/// dates or null for unreadable).
final List<String>? cache;
/// Whether the cache has been read at least once.
final bool cacheRead;
/// Whether [status] is a refused tap, drawn like an error.
final bool refused;
static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
String _label(DateTime day) =>
'${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}';
String _toDdMmYyyy(DateTime day) {
final d = day.day.toString().padLeft(2, '0');
final m = day.month.toString().padLeft(2, '0');
return '$d.$m.${day.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final statusColors = theme.extension<AppStatusColors>()!;
final failed = refused || status.contains('Error');
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Tap a day to mark it sick; tap it again to clear it. Today or '
'later only. No limit and no reason needed.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'On a sick day the alarm stays silent, nothing locks, and work '
'left undone is dropped rather than carried forward.',
style: theme.textTheme.bodySmall?.copyWith(color: muted),
),
if (status.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
status,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: failed ? theme.colorScheme.error : statusColors.success,
),
StatusCue(
semanticLabel: status,
kind: failed ? CueKind.error : null,
),
],
if (cache.isNotEmpty) ...[
if (cacheRead) ...[
const SizedBox(height: 8),
Text(cache, style: TextStyle(color: muted)),
Row(
children: [
Icon(
Icons.memory,
size: 16,
semanticLabel: _cacheSemanticLabel,
),
const SizedBox(width: 8),
_buildCacheContent(theme),
],
),
],
const SizedBox(height: 16),
calendar,
const SizedBox(height: 24),
Text('Marked sick', style: theme.textTheme.titleMedium),
const Row(
children: [Icon(Icons.sick_outlined, semanticLabel: 'Marked sick')],
),
const SizedBox(height: 8),
if (upcoming.isEmpty)
Text('None ahead.', style: TextStyle(color: muted)),
const SpokenLabel(
semanticLabel: 'None ahead.',
child: DashedSlot(),
),
for (final day in upcoming)
ListTile(contentPadding: EdgeInsets.zero, title: Text(_label(day))),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_toDdMmYyyy(day)),
subtitle: _keptIcons(keptOf(day)),
),
],
),
);
}
String get _cacheSemanticLabel {
if (cache == null) return 'Alarm cache: unreadable';
if (cache!.isEmpty) return 'Alarm cache: none';
return 'Alarm cache: ${cache!.join(', ')}';
}
Widget _buildCacheContent(ThemeData theme) {
if (cache == null) {
return Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 16,
);
}
if (cache!.isEmpty) {
return Icon(
Icons.check,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
);
}
return Text(
cache!.join(', '),
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
);
}
Widget? _keptIcons(Set<String> kept) {
if (kept.isEmpty) return null;
return Row(
children: [
for (final locker in SickLocker.all)
if (kept.contains(locker))
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(sickLockerIcons[locker], size: 16),
),
],
);
}
}

View File

@ -1,25 +1,29 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/screens/sick_day_sheet.dart';
import 'package:wake_alarm_sync/screens/sick_days_body.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/free_day_sync.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_sync.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The phone's picker for the shared sick-day pool (`~/src/utils/sickdays`).
///
/// Not the free-day pool, and drawn so it cannot be mistaken for it: its own
/// name, icon and fill. Every day is normal until tapped; one tap marks a
/// day sick, another clears it. Today and later only -- past days are
/// inert. No cap, no reason, no confirmation.
/// name, icon and fill. Every day is normal until marked. Tapping today or
/// a later day opens [SickDaySheet]: one switch per locker, all off, and
/// "Mark sick"; on a sick day, its current switches, "Save" (a keep-only
/// edit) and "Clear sick day". Past days are inert. No cap, no reason.
///
/// On a sick day every gate stands down and this phone does not ring. A
/// free day is refused here (with the pool named), so the free-day pool is
/// synced on open as well.
/// On a sick day every locker left off stands down; a kept one behaves as
/// on a normal day (the cell carries a dot). This phone does not ring only
/// when the alarm is not kept. A free day is refused before the sheet opens
/// (with the pool named), so the free-day pool is synced on open as well.
class SickDaysScreen extends StatefulWidget {
/// Creates the picker; the seams are injectable for tests.
const SickDaysScreen({
@ -65,7 +69,8 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
bool _refused = false;
/// What the native ring-path cache holds, read back over the channel.
String _cache = '';
List<String>? _cache;
bool _cacheRead = false;
late DateTime _month = DateTime(_today.year, _today.month);
@ -109,11 +114,8 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
}
if (!mounted) return;
setState(() {
_cache = 'Alarm cache: ${switch (dates) {
null => 'unreadable',
[] => 'none',
final d => d.join(', '),
}}';
_cache = dates;
_cacheRead = true;
});
}
@ -146,24 +148,29 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
});
}
/// One tap per day: a sick day is cleared, any other is marked sick.
Future<void> _toggle(DateTime day) async {
/// Opens the per-locker sheet for [day] and applies what it returns.
Future<void> _onTapDay(DateTime day) async {
final pool = _pool;
if (pool == null) return;
final today = _today;
final sick = pool.isSick(day);
final refusal = sick ? null : freeDayRefusal(_free, day);
if (refusal != null) {
setState(() {
_refused = true;
_status = refusal;
});
return;
}
final result = await showSickDaySheet(
context,
day: day,
isSick: sick,
kept: pool.keptLockers(day),
);
if (result == null || !mounted) return;
final bool wrote;
try {
if (pool.isSick(day)) {
await pool.remove(day, now: today);
} else {
final free = _free;
if (free == null) {
throw SickDayPolicyError(
'Free-day pool unavailable — cannot rule out a free day on '
'${FreeDayPool.toIso(day)}.',
);
}
await pool.add(day, isFree: free.isFree, now: today);
}
wrote = await applySickSheet(pool, day, result, free: _free, now: _today);
} on SickDayPolicyError catch (e) {
setState(() {
_refused = true;
@ -171,7 +178,7 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
});
return;
}
await _sync();
if (wrote) await _sync();
}
@override
@ -180,41 +187,41 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
final marks = Theme.of(context).extension<DayMarkColors>()!;
return Scaffold(
appBar: AppBar(
title: const Row(
children: [
Icon(Icons.sick_outlined),
SizedBox(width: 8),
Text('Sick days'),
],
),
title: const Row(children: [Icon(Icons.sick_outlined)]),
actions: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync now',
icon: const Icon(Icons.sync, semanticLabel: 'Sync now'),
onPressed: _busy ? null : () => unawaited(_sync()),
),
],
),
body: pool == null
? Center(child: Text(_status.isEmpty ? 'Loading…' : _status))
? Center(
child: _status.isEmpty
? const CircularProgressIndicator()
: StatusCue(semanticLabel: _status),
)
: SickDaysBody(
upcoming: [
for (final day in pool.sickDays())
if (!day.isBefore(_today)) day,
],
keptOf: pool.keptLockers,
status: _status,
cache: _cache,
cacheRead: _cacheRead,
refused: _refused,
calendar: DayCalendar(
month: _month,
today: _today,
isMarked: pool.isSick,
hasBadge: (day) => pool.keptLockers(day).isNotEmpty,
busy: _busy,
todayTappable: true,
keyPrefix: 'sick-day',
markColor: marks.sick,
onMarkColor: marks.onSick,
onTapDay: (day) => unawaited(_toggle(day)),
onTapDay: (day) => unawaited(_onTapDay(day)),
onMonth: (month) => setState(() => _month = month),
// Back to January for this year's history; forward to the
// end of next year, the same horizon as the free days.

View File

@ -1,13 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/escape_dialog.dart';
import 'package:wake_alarm_sync/screens/stakes_marks.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/enforcement_bridge.dart';
import 'package:wake_alarm_sync/services/enforcement_state.dart';
import 'package:wake_alarm_sync/services/escape_hatch.dart';
import 'package:wake_alarm_sync/services/today_stakes.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// Today's stakes in plain words, anything already in force, the streak,
/// and the escape hatch while it lasts.
@ -25,6 +27,7 @@ class StakesCard extends StatefulWidget {
this.nextAlarm,
this.onEscaped,
this.busy = false,
this.emptyText,
});
/// Native stick reader; defaults to the real channel.
@ -45,6 +48,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 +63,8 @@ class _StakesCardState extends State<StakesCard> {
EnforcementState? _state;
AlarmTime? _next;
String _message = '';
bool _excused = false;
bool _loaded = false;
DateTime _now() => (widget.now ?? DateTime.now)();
@ -78,6 +87,7 @@ class _StakesCardState extends State<StakesCard> {
setState(() {
_state = state;
_next = next;
_loaded = true;
});
}
@ -95,6 +105,7 @@ class _StakesCardState extends State<StakesCard> {
if (reason == null) return;
final ok = await _hatch.use(reason);
if (!mounted) return;
_excused = ok;
setState(
() => _message = ok
? 'Excused — lockdown lifted, publishing to the PC.'
@ -107,11 +118,14 @@ 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
? SpokenLabel(semanticLabel: empty, child: const DashedSlot())
: const SizedBox.shrink();
}
final theme = Theme.of(context);
final warning = theme.extension<AppStatusColors>()?.warning;
final view = describeStakes(now: _now(), state: state, nextAlarm: _next);
final body = theme.textTheme.bodyMedium;
return Card(
color: theme.colorScheme.surfaceContainerHigh,
margin: EdgeInsets.zero,
@ -120,37 +134,25 @@ class _StakesCardState extends State<StakesCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(view.title, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final line in view.stakes)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(line, style: body),
),
for (final line in view.pending)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(line, style: body?.copyWith(color: warning)),
),
const SizedBox(height: 8),
Text(
view.streak,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
StakesMarks(view: view),
if (_message.isNotEmpty) ...[
const SizedBox(height: 8),
Text(_message, style: body),
StatusCue(
semanticLabel: _message,
kind: _excused ? CueKind.ok : CueKind.error,
),
],
if (_hatch.isOpen) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
child: IconButton(
key: const Key('stakes-escape'),
onPressed: widget.busy ? null : () => unawaited(_escape()),
icon: const Icon(Icons.bug_report_outlined),
label: const Text('That was a bug'),
icon: const Icon(
Icons.bug_report_outlined,
semanticLabel: 'That was a bug',
),
),
),
],

View File

@ -0,0 +1,135 @@
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/today_stakes.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The stakes of [view] as glyph rows: times and dates are the only
/// characters painted; each row speaks its sentence from [StakesView].
class StakesMarks extends StatelessWidget {
/// Creates the rows for [view].
const StakesMarks({required this.view, super.key});
/// The stakes to draw.
final StakesView view;
static String _dm(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.'
'${d.month.toString().padLeft(2, '0')}';
static String _hm(int h, int m) =>
'${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final warning =
theme.extension<AppStatusColors>()?.warning ?? theme.colorScheme.error;
final kindIcon = switch (view.kind) {
StakesDayKind.free => Icons.event_available,
StakesDayKind.sick => Icons.sick_outlined,
StakesDayKind.stick => Icons.gavel_outlined,
StakesDayKind.carrot => Icons.emoji_events_outlined,
};
final rows = <Widget>[
_row('title', view.title, <Widget>[
Icon(kindIcon, color: theme.colorScheme.primary),
Icon(view.isToday ? Icons.today : Icons.event, size: 18),
Text(_dm(view.day), style: theme.textTheme.titleMedium),
]),
];
final words = view.stakes;
final dismissBy = view.dismissBy;
final lock = view.lockThrough;
final finishBy = view.finishBy;
final open = view.pcOpenUntil;
final ring = view.pcRingAt;
if (dismissBy != null && lock != null) {
rows.add(
_row('stick', words.first, <Widget>[
const Icon(Icons.alarm),
Text(hhmm(dismissBy)),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.lock_outline),
Text(_dm(lock)),
]),
);
} else if (finishBy != null && open != null) {
rows.add(
_row('carrot', words.first, <Widget>[
const Icon(Icons.flag_outlined),
Text(hhmm(finishBy)),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.computer),
Text(_hm(open.hour, open.minute)),
]),
);
} else if (view.kind != StakesDayKind.free &&
view.kind != StakesDayKind.sick) {
rows.add(
_row('unarmed', words.first, const <Widget>[Icon(Icons.alarm_off)]),
);
}
if (ring != null) {
rows.add(
_row('pc-ring', words.last, <Widget>[
const Icon(Icons.computer),
const Icon(Icons.notifications_active_outlined),
Text(hhmm(ring)),
]),
);
}
rows
..addAll(_pending(warning))
..add(
_row('streak', view.streak, <Widget>[
Icon(
Icons.local_fire_department_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
Text('${view.streakCount}'),
]),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rows,
);
}
/// The rows for what is already in force, in `view.pending`'s order.
List<Widget> _pending(Color warning) {
final spoken = view.pending.iterator;
String next() => spoken.moveNext() ? spoken.current : '';
final locked = view.lockedThrough;
final style = TextStyle(color: warning);
return <Widget>[
if (view.escapedToday)
_row('escaped', next(), <Widget>[
Icon(Icons.bug_report_outlined, color: warning),
Icon(Icons.check, color: warning),
]),
if (locked != null)
_row('locked', next(), <Widget>[
Icon(Icons.lock_outline, color: warning),
Text(_dm(locked), style: style),
]),
if (view.missedToday)
_row('missed', next(), <Widget>[
Icon(Icons.close, color: warning),
Icon(Icons.computer, color: warning),
]),
];
}
static Widget _row(String what, String words, List<Widget> marks) => Padding(
key: ValueKey<String>('stakes-$what'),
padding: const EdgeInsets.symmetric(vertical: 4),
child: SpokenLabel(
semanticLabel: words,
child: Row(
children: <Widget>[
for (final mark in marks) ...<Widget>[mark, const SizedBox(width: 8)],
],
),
),
);
}

View File

@ -0,0 +1,59 @@
/// End-to-end encryption of the alarm record this app publishes.
///
/// The record (alarm time, tags, home location, morning sessions, weigh-ins,
/// enforcement fields) is sealed with a key derived from the shared
/// crdt-sync passphrase in the OS keystore, under
/// `wake-alarm-sync/devices-e2e`; Firebase and the GitHub mirror only see
/// the record id and clocks. The PC (`wake_alarm/_sync_transport.py`) and
/// diet_guard (its weigh-in ingest) read the same layout.
///
/// Fail closed: without a usable key nothing is pulled or pushed. The old
/// plaintext prefix is copied over on open and never written or deleted.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show openEncryptedStoreFromKeystore;
import 'package:flutter/foundation.dart';
/// The plaintext prefix every pre-E2E build wrote; only the copy reads it.
const legacyAlarmPrefix = 'wake-alarm-sync/devices';
/// Where the alarm record now lives, and where its revisions go.
final E2eLayout alarmLayout = E2eLayout.of(legacyAlarmPrefix);
/// Argon2id cost for a header this device creates; only tests lower it.
@visibleForTesting
Argon2Params e2eArgon2Params = Argon2Params.recommended;
/// Whether the last open found no usable key, for the lock button.
final ValueNotifier<bool> alarmSyncLocked = ValueNotifier<bool>(false);
/// Thrown instead of syncing when end-to-end encryption has no usable key.
class SyncLockedException implements Exception {
/// Creates the exception for [reason].
const SyncLockedException(this.reason);
/// Why the store is locked.
final E2eLockReason reason;
@override
String toString() => 'sync locked (${reason.name})';
}
/// The encrypted view of [backend], the outermost store (above the mirror).
///
/// Throws [SyncLockedException] when locked, so no caller can fall through
/// to a plaintext push.
Future<RemoteStore> openAlarmStore(RemoteStore backend) async {
final state = await openEncryptedStoreFromKeystore(
store: backend,
oldPrefix: legacyAlarmPrefix,
params: e2eArgon2Params,
);
alarmSyncLocked.value = state is! E2eReady;
if (state is E2eReady) return state.store;
throw SyncLockedException(
state is E2eLocked ? state.reason : E2eLockReason.badHeader,
);
}

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'package:wake_alarm_sync/services/alarm_sync_e2e.dart';
import 'package:wake_alarm_sync/services/firebase_backend.dart';
/// The alarm time this device holds, as read from or written to the store.
@ -17,7 +18,8 @@ typedef Alarm = ({int hour, int minute});
///
/// The phone is the **sole writer**: it stores the chosen alarm locally in a
/// [LogStore] (one HLC-stamped [Record], id [_recordId]) and pushes that log
/// to `wake-alarm-sync/devices/<deviceId>/alarm.json` — Firebase first,
/// to `wake-alarm-sync/devices-e2e/<deviceId>/alarm.json`, end-to-end
/// encrypted (`alarm_sync_e2e.dart`) — Firebase first,
/// mirrored to the private `kuhyx/syncs` repo during the cutover (see
/// [syncBackend]). The path is deliberately identical on both backends, so
/// only the transport differs.
@ -117,6 +119,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
@ -156,11 +164,17 @@ class AlarmSyncService {
/// The pull is what brings the tags and home location back to a fresh
/// install. The alarm time itself is unaffected: [setLocal] runs first on
/// every publish, so the local time is always the newest and wins.
///
/// Everything goes through the end-to-end encrypted view of [client]
/// (`alarm_sync_e2e.dart`); a locked device throws [SyncLockedException]
/// before reading or writing anything.
Future<void> pushWith(RemoteStore client) async {
final sealed = await openAlarmStore(client);
final merged = await syncLog(
client: client,
client: sealed,
deviceId: deviceId,
pathPrefix: 'wake-alarm-sync/devices',
pathPrefix: alarmLayout.prefix,
revsPath: alarmLayout.revsPath,
filename: 'alarm.json',
localLog: _store.snapshot(),
encode: logToJson,

View File

@ -178,7 +178,7 @@ class ChallengeGenerator {
final backwards = word.split('').reversed.join();
return Challenge(
kind: ChallengeKind.reverse,
display: 'Type this word backwards:\n$word',
display: word,
answer: backwards,
hint: 'Type the word backwards',
);

View 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());
}

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -115,9 +115,8 @@ class HomePresence {
);
}
static String _readableDistance(double metres) => metres >= 1000
? '${(metres / 1000).round()} km'
: '${metres.round()} m';
static String _readableDistance(double metres) =>
metres >= 1000 ? '${(metres / 1000).round()} km' : '${metres.round()} m';
static String _reasonText(String? reason) => switch (reason) {
'no-permission' => 'location permission denied',

View File

@ -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.

View File

@ -76,9 +76,5 @@ Future<RingStartup> resolveRingStartup({
: 'NFC is off or unavailable.',
);
}
return RingStartup(
settings: settings,
skip: RingSkip.none,
status: '',
);
return RingStartup(settings: settings, skip: RingSkip.none, status: '');
}

View File

@ -34,11 +34,20 @@ Future<void> pointNativeAtLeg(
);
}
/// The notification's one line: the leg to satisfy, or what happens instead.
/// The notification's one line: glyph + position count, with a running-clock
/// marker when the re-ring was spent.
String noticeLabel(MorningSession session) {
final leg = session.currentLeg!;
final glyphs = <SessionLeg, String>{
SessionLeg.bathroomIn: '🛁',
SessionLeg.showerDone: '🚿',
SessionLeg.dressed: '👕',
SessionLeg.atDesk: '🪑',
};
final glyph = glyphs[leg] ?? '•';
var text = '$glyph ${leg.index + 1}/${SessionLeg.values.length}';
if (session.deadlineRung.contains(leg)) {
return '${leg.label} — passes by itself soon';
text += ' ⏳';
}
return leg.label;
return text;
}

View File

@ -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;

View File

@ -0,0 +1,82 @@
import 'dart:developer' as developer;
import 'package:crdt_sync/crdt_sync.dart';
/// The per-day keep set (sickdays 0.2.0): which lockers stay active on a
/// sick day. A Dart port of `sickdays/_keep.py`; the contract is the
/// README's "keep (0.2.0)" section.
///
/// `keep` is one more last-writer-wins field on the day's record, holding a
/// **sorted, de-duplicated** list of locker ids. Sorting is wire format, not
/// tidiness: the RTDB rev is a SHA-256 of the exact serialised string.
///
/// Reading is where the safety lives ([readKeep]): a missing `keep` (every
/// 0.1.0 record) or one stamped older than `state` keeps nothing; a value
/// that is not a list of strings keeps *everything* (the locks apply); an
/// unknown id inside a valid list is ignored, for forward compatibility.
abstract final class SickLocker {
/// screen-locker's workout lock.
static const workout = 'workout';
/// leetcode-guard.
static const leetcode = 'leetcode';
/// The book locker.
static const book = 'book';
/// diet_guard.
static const diet = 'diet';
/// This app: the phone ring, the PC fallback ring, the RTC self-wake, the
/// missed-alarm penalty / stick and the morning routine.
static const alarm = 'alarm';
/// Every known id, in `sickdays.LOCKERS` order.
static const all = <String>[workout, leetcode, book, diet, alarm];
/// Returns [locker] if it is a known id; throws [ArgumentError] otherwise
/// (a programmer error, never a data problem).
static String validate(String locker) {
if (!all.contains(locker)) {
throw ArgumentError.value(
locker,
'locker',
'unknown locker; expected one of ${all.join(', ')}',
);
}
return locker;
}
}
/// The record field holding the keep set.
const keepField = 'keep';
/// The on-the-wire value of [keep]: validated, de-duplicated and sorted, so
/// two devices holding the same set write the same bytes.
List<String> keepWire(Iterable<String> keep) =>
keep.map(SickLocker.validate).toSet().toList()..sort();
/// Which known lockers [record]'s day keeps active.
///
/// Only meaningful while the day is sick; callers check that first. The
/// rules run in the README's order: missing -> keep nothing; older than
/// `state` -> stale, keep nothing (a stale malformed value is just stale);
/// not a list of strings -> keep every locker, logged; else the list minus
/// ids this version does not know.
Set<String> readKeep(Record record) {
final field = record.fields[keepField];
if (field == null) return <String>{};
final (value, hlc) = field;
final state = record.fields['state'];
if (state != null && hlc < state.$2) return <String>{};
if (value is! List || value.any((v) => v is! String)) {
developer.log(
'sick day ${record.id} has a malformed keep field $value -- every '
'locker applies',
name: 'SickDayPool',
level: 900,
);
return SickLocker.all.toSet();
}
return value.cast<String>().where(SickLocker.all.contains).toSet();
}

View File

@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
/// The shared sick-day pool, as the phone sees it.
///
@ -14,9 +15,17 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart';
/// marked or cleared; no cap, no reason, no countdown; and a free day is
/// never also a sick day. The record shape is the Python package's wire
/// format: the record id *is* the ISO date, fields `state` (`sick` /
/// `cleared`) and `actor`, both stamped with one HLC tick. Clearing flips
/// `state` rather than tombstoning, because crdt tombstones are monotonic
/// and a deleted date could never be marked sick again.
/// `cleared`), `actor` and `keep` (0.2.0, `sick_day_keep.dart`), all three
/// stamped with one HLC tick on a mark or a clear. Clearing flips `state`
/// rather than tombstoning, because crdt tombstones are monotonic and a
/// deleted date could never be marked sick again. A keep-only edit
/// ([setKept]) re-stamps `keep` and `actor` only.
///
/// Every tick comes from [LogStore.nextHlc], which the store keeps after
/// the greatest HLC of *any* node in the log -- so a tick is always later
/// than both this device's last one and everything on the day's record,
/// which is the contract's "never older than the state it was made
/// against".
///
/// This is not the free-day pool: its own file, its own remote node
/// (`sickdays-sync/devices`), its own native cache. Nothing here touches
@ -85,19 +94,46 @@ class SickDayPool {
return days..sort();
}
/// Every ISO date currently sick, past ones included, for the native
/// cache: the stick skips past sick days when it walks back the streak.
/// Every ISO date currently sick, past ones included, whatever it keeps.
List<String> sickIsoDates() => sickDays().map(FreeDayPool.toIso).toList();
/// The lockers [day] keeps active; empty when the day is not sick.
Set<String> keptLockers(DateTime day) {
final record = _store.get(FreeDayPool.toIso(FreeDayPool.dateOnly(day)));
if (record == null || !_isSick(record)) return <String>{};
return readKeep(record);
}
/// Whether [locker] is switched off on [day]: the day is sick and does
/// not keep it. Throws [ArgumentError] for an unknown locker id.
bool standsDown(String locker, DateTime day) {
SickLocker.validate(locker);
return isSick(day) && !keptLockers(day).contains(locker);
}
/// Every ISO date on which [locker] stands down, past ones included.
///
/// With [SickLocker.alarm] this is the native cache: the ring, the stick
/// (which skips past sick days when it walks back the streak) and the
/// stakes card must only see days on which the alarm stands down.
List<String> standDownIsoDates(String locker) => [
for (final day in sickDays())
if (standsDown(locker, day)) FreeDayPool.toIso(day),
];
/// Marks [day] sick, everywhere, for every app.
///
/// Throws [SickDayPolicyError] for a past day, a day already sick, or a
/// day [isFree] says is in the free-day pool. [now] is injectable.
/// [keep] names the lockers that stay active (default: none, so every
/// locker stands down). Throws [ArgumentError] for an unknown locker id,
/// and [SickDayPolicyError] for a past day, a day already sick, or a day
/// [isFree] says is in the free-day pool. [now] is injectable.
Future<void> add(
DateTime day, {
required bool Function(DateTime day) isFree,
DateTime? now,
Iterable<String> keep = const [],
}) async {
final wire = keepWire(keep);
final id = _checkNotPast(day, now, 'marked');
if (_isSick(_store.get(id))) {
throw SickDayPolicyError('$id is already a sick day.');
@ -107,7 +143,7 @@ class SickDayPool {
'$id is a free day — release it in Free days first.',
);
}
await _write(id, sick: true);
await _write(id, sick: true, keep: wire);
}
/// Clears [day] back to a normal day.
@ -118,7 +154,40 @@ class SickDayPool {
if (!_isSick(_store.get(id))) {
throw SickDayPolicyError('$id is not a sick day.');
}
await _write(id, sick: false);
await _write(id, sick: false, keep: const []);
}
/// Replaces the kept lockers of the existing sick day [day].
///
/// Copies the record and re-stamps only `keep` and `actor`, with one
/// fresh tick: `state` keeps its own clock, so this edit can never
/// re-assert "sick" over a concurrent clear from another device. An empty
/// [keep] stands every locker down again. Throws [ArgumentError] for an
/// unknown id and [SickDayPolicyError] for a past day or a normal day.
Future<void> setKept(
DateTime day,
Iterable<String> keep, {
DateTime? now,
}) async {
final wire = keepWire(keep);
final id = _checkNotPast(day, now, 'changed');
final existing = _store.get(id);
if (existing == null || !_isSick(existing)) {
throw SickDayPolicyError('$id is not a sick day.');
}
final hlc = _store.nextHlc();
await _store.upsert(
Record(
id: id,
fields: {
...existing.fields,
keepField: (wire, hlc),
_actorField: (deviceId, hlc),
},
deleted: existing.deleted,
deletedHlc: existing.deletedHlc,
),
);
}
/// The ISO id of [day], or a refusal when it is before [now]'s date.
@ -134,8 +203,14 @@ class SickDayPool {
return id;
}
/// Both fields share one tick, as the Python writer does.
Future<void> _write(String id, {required bool sick}) async {
/// Every field shares one tick, as the Python writer does; `keep` is
/// always written (`[]` on a clear), so a keep set from before a clear can
/// never survive a later re-add.
Future<void> _write(
String id, {
required bool sick,
required List<String> keep,
}) async {
final hlc = _store.nextHlc();
await _store.upsert(
Record(
@ -143,13 +218,15 @@ class SickDayPool {
fields: {
_stateField: (sick ? _sick : _cleared, hlc),
_actorField: (deviceId, hlc),
keepField: (keep, hlc),
},
),
);
}
}
/// An add or remove the pool's rules refuse. The message is for the user.
/// An add, remove or keep edit the pool's rules refuse; the message is for
/// the user.
class SickDayPolicyError implements Exception {
/// Creates the refusal with its user-facing [message].
SickDayPolicyError(this.message);

View File

@ -2,6 +2,7 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/day_pool_sync.dart';
import 'package:wake_alarm_sync/services/firebase_backend.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
/// Moves the sick-day pool between this phone and every other device, over
@ -9,7 +10,9 @@ import 'package:wake_alarm_sync/services/sick_day_pool.dart';
///
/// Every sync ends by handing the merged pool to the native ring path
/// ([AlarmScheduler.setSickDays]): that cache, not the Dart store, is what
/// `AlarmReceiver` reads when it decides whether to ring.
/// `AlarmReceiver` reads when it decides whether to ring. Only days on
/// which the *alarm* stands down go there: a sick day that keeps the alarm
/// rings, judges the stick and shows its stakes exactly like a normal day.
class SickDaySync {
/// Creates a syncer; both seams are injectable for tests.
SickDaySync({
@ -35,9 +38,12 @@ class SickDaySync {
logName: 'SickDaySync',
);
/// Writes the pool's sick dates where `AlarmReceiver` can read them.
/// Writes the dates on which the alarm stands down (sick, alarm not
/// kept; past ones included) where `AlarmReceiver` can read them. The one
/// place the keep set is applied for the native side: every native reader
/// (ring, stick, stakes) means "the alarm stands down".
Future<void> publishToNative(SickDayPool pool) => publishQuietly(
() => _scheduler.setSickDays(pool.sickIsoDates()),
() => _scheduler.setSickDays(pool.standDownIsoDates(SickLocker.alarm)),
logName: 'SickDaySync',
);
}

View File

@ -0,0 +1,91 @@
import 'package:wake_alarm_sync/services/stakes.dart';
/// Which kind of morning the stakes describe.
enum StakesDayKind {
/// A booked free day: nothing rings.
free,
/// A marked sick day: nothing rings.
sick,
/// A workday: a miss locks the apps.
stick,
/// A weekend day: finishing in time opens the PC early.
carrot,
}
/// What the stakes card shows: the facts, and the same facts in words.
///
/// The card paints only the facts (glyphs, times, dates); the sentences are
/// its spoken labels and what the tests read for the wording. Built by
/// `describeStakes` in `today_stakes.dart`.
class StakesView {
/// Creates a view.
const StakesView({
required this.title,
required this.stakes,
required this.pending,
required this.streak,
required this.kind,
required this.isToday,
required this.day,
required this.streakCount,
this.dismissBy,
this.lockThrough,
this.finishBy,
this.pcOpenUntil,
this.pcRingAt,
this.escapedToday = false,
this.lockedThrough,
this.missedToday = false,
});
/// The kind of the coming morning.
final StakesDayKind kind;
/// Whether the coming morning is today (else tomorrow).
final bool isToday;
/// The coming morning's date.
final DateTime day;
/// Missed stick days in a row.
final int streakCount;
/// Stick day: the dismiss deadline. Null when nothing is armed.
final DateTime? dismissBy;
/// Stick day: the last day a miss locks the apps through.
final DateTime? lockThrough;
/// Carrot day: the time the morning must be finished by.
final DateTime? finishBy;
/// Carrot day: how long a finished morning keeps the PC open.
final ClockTime? pcOpenUntil;
/// When the PC rings if the phone stays silent. Null when nothing rings.
final DateTime? pcRingAt;
/// This morning was excused.
final bool escapedToday;
/// Apps already locked through this day, when a lockdown is in force.
final DateTime? lockedThrough;
/// This morning was missed (and not excused).
final bool missedToday;
/// "Today · Thu 2 Oct — stick day" (spoken).
final String title;
/// What the coming morning costs or earns (spoken, one per row).
final List<String> stakes;
/// A lockdown, penalty or escape already in force (spoken, one per row).
final List<String> pending;
/// The streak line (spoken).
final String streak;
}

View File

@ -1,33 +1,13 @@
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
import 'package:wake_alarm_sync/services/enforcement_state.dart';
import 'package:wake_alarm_sync/services/stakes.dart';
import 'package:wake_alarm_sync/services/stakes_view.dart';
export 'package:wake_alarm_sync/services/stakes_view.dart';
/// An alarm time of day.
typedef AlarmTime = ClockTime;
/// What the stakes card says, already in words.
class StakesView {
/// Creates a view.
const StakesView({
required this.title,
required this.stakes,
required this.pending,
required this.streak,
});
/// "Today · Thu 2 Oct — stick day".
final String title;
/// What the coming morning costs or earns.
final List<String> stakes;
/// A lockdown, penalty or escape already in force.
final List<String> pending;
/// The streak line.
final String streak;
}
/// The stakes of the next undecided morning, from the native [state].
///
/// That is today while today's alarm is still open (not dismissed, not
@ -59,6 +39,10 @@ StakesView describeStakes({
? 'sick day'
: (stick ? 'stick day' : 'carrot day');
final lines = <String>[];
DateTime? dismissBy;
DateTime? lockThrough;
DateTime? finishBy;
DateTime? pcRingAt;
if (alarm == null) {
lines.add('No alarm armed yet — nothing at stake.');
} else if (free) {
@ -74,21 +58,26 @@ StakesView describeStakes({
final next = todayOpen
? "tomorrow's"
: "${_weekday(_plusDays(day, 1))}'s";
dismissBy = ringAt.add(stickGrace);
lockThrough = lock;
lines.add(
'Miss (no dismiss by ${hhmm(ringAt.add(stickGrace))}) → apps '
'Miss (no dismiss by ${hhmm(dismissBy)}) → apps '
'locked through ${formatDay(lock)} + $next PC leniency gone',
);
} else {
finishBy = carrotCutoff(ringAt);
lines.add(
'Finish the morning by ${hhmm(carrotCutoff(ringAt))} → PC open '
'Finish the morning by ${hhmm(finishBy)} → PC open '
'until ${_hhmm(completedUntil.hour, completedUntil.minute)}',
);
}
pcRingAt = ringAt.add(pcPhoneSilentAfter);
lines.add(
'PC rings at ${hhmm(ringAt.add(pcPhoneSilentAfter))} if the phone is '
'PC rings at ${hhmm(pcRingAt)} if the phone is '
'silent (up to ${pcRingCap.inMinutes} min)',
);
}
final locked = state.lockedThrough;
return StakesView(
title: '$when · ${formatDay(day)} — $kind',
stakes: lines,
@ -96,6 +85,25 @@ StakesView describeStakes({
streak:
'Streak: ${state.streak} missed stick '
'day${state.streak == 1 ? '' : 's'} in a row',
kind: free
? StakesDayKind.free
: sick
? StakesDayKind.sick
: (stick ? StakesDayKind.stick : StakesDayKind.carrot),
isToday: todayOpen,
day: day,
streakCount: state.streak,
dismissBy: dismissBy,
lockThrough: lockThrough,
finishBy: finishBy,
pcOpenUntil: finishBy == null ? null : completedUntil,
pcRingAt: pcRingAt,
escapedToday: state.escapedToday,
lockedThrough:
locked != null && !locked.isBefore(today) && !state.escapedToday
? locked
: null,
missedToday: state.lastMiss == today && !state.escapedToday,
);
}
@ -103,6 +111,16 @@ StakesView describeStakes({
/// or null when this ring has none (not a stick day, free, sick, already
/// dismissed, excused or missed).
String? ringStakesLine(EnforcementState state) {
final stakes = ringStakes(state);
if (stakes == null) return null;
return 'Stick day: no dismiss by ${hhmm(stakes.by)} → apps locked through '
'${formatDay(stakes.lockThrough)}';
}
/// Returns the deadline and lockdown date for the ring stakes line, or null
/// when this ring has none (not a stick day, free, sick, already dismissed,
/// excused or missed).
({DateTime by, DateTime lockThrough})? ringStakes(EnforcementState state) {
final stored = _stored(state);
if (stored == null ||
!isWorkday(state.today) ||
@ -114,8 +132,7 @@ String? ringStakesLine(EnforcementState state) {
if (state.lastMiss == state.today) return null;
final by = _at(state.today, stored).add(stickGrace);
final lock = state.lockIfMissToday ?? _plusDays(state.today, 1);
return 'Stick day: no dismiss by ${hhmm(by)} → apps locked through '
'${formatDay(lock)}';
return (by: by, lockThrough: lock);
}
/// The carrot's cutoff for a morning whose alarm rings at [ringAt]:

Some files were not shown because too many files have changed in this diff Show More