Compare commits
33 Commits
v1.0.0-bui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9250db4cc1 | |||
| 9dbc4547ee | |||
| 3b7f561452 | |||
| 8e6fa90c80 | |||
| 6cb51ad0f2 | |||
| 4dbde2b525 | |||
| 28327d322a | |||
| 6c248e712e | |||
| 52c3ff5f1e | |||
| 89e6eed105 | |||
| 06b70ab312 | |||
| 10938ed012 | |||
| f182453567 | |||
| 537a3a79e4 | |||
| 672eaf8aba | |||
| 13f6e3f5d2 | |||
| 5525ca6f95 | |||
| 58c4260b6e | |||
| 6033e3815f | |||
| 18a304de55 | |||
| a51a67e2fe | |||
| fd65c454e3 | |||
| 05738db15a | |||
| 69be24d4c0 | |||
| 1c2197a3cd | |||
| 2defe09e6a | |||
| 4b72899e5b | |||
| 14b6dac605 | |||
| 0060b48b10 | |||
| 597901dc08 | |||
| ee7e380278 | |||
| 7fe6108df3 | |||
| 5d0f06b19e |
2
.github/workflows/python-tests.yml
vendored
@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||
with:
|
||||
flutter-version: 3.47.6
|
||||
flutter-version: 3.50.0
|
||||
working-directory: phone_app
|
||||
|
||||
- name: Dart writes, Python replays, both must agree
|
||||
|
||||
2
.github/workflows/release-apk.yml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
#
|
||||
|
||||
16
AGENTS.md
@ -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` /
|
||||
|
||||
@ -82,8 +82,18 @@ cutoffs, neither imports `crdt_sync`, neither talks to Firebase:
|
||||
**A PC booted mid-morning** has a missing or yesterday's file until the
|
||||
timer's `Persistent=true` catch-up lands, and that needs the network. Only
|
||||
the enforce path, only inside 05:00–11:00, and only when the file is not
|
||||
today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as
|
||||
today's, both consumers retry for ≤ 180 s (5 s steps) and then proceed as
|
||||
normal with a WARNING. Status paths never wait; tests pin the retry to 0.
|
||||
The refresher itself retries an unreadable backend every 5 s for 150 s
|
||||
(`REFRESH_RETRY_SECONDS`) before exiting 1 with a WARNING: a PC booted at
|
||||
07:48 had no DNS for its first seconds, and the old single try left the next
|
||||
chance at the 07:50 tick (2026-11-07).
|
||||
|
||||
**`next_step`** (signed with the rest): while the session is `in_progress`,
|
||||
the label of the first leg not in the phone's `passed` map, else `null`.
|
||||
Names and labels live in `_session_legs.py` and are pinned to the phone's
|
||||
`SessionLeg` by `wake_alarm/tests/fixtures/session_legs.json`, asserted by a
|
||||
Python and a Dart test.
|
||||
|
||||
## Known limits (v1, deliberate)
|
||||
|
||||
|
||||
@ -25,6 +25,10 @@ weigh in -> bathroom tag (≤ 2 min later) -> shower -> bathroom tag again
|
||||
| `dressed` | wardrobe tag | previous + 10 min | |
|
||||
| `atDesk` | desk tag | previous + 10 min | |
|
||||
|
||||
**A later tag catches up a forgotten one** (`LegPass.caughtUp`, 2026-11-01:
|
||||
shower tag forgotten, re-rung while dressing). The weigh-in never catches
|
||||
up, and the shower's 8-minute floor still applies.
|
||||
|
||||
Legs latch like the ring's stages. A leg whose tag is not registered is
|
||||
**skipped at start** (`LegPass.skipped`), so the feature is usable before it
|
||||
is configured: with no session tags at all it is a weigh-in and nothing
|
||||
@ -58,6 +62,8 @@ the first leg by itself — there is nothing to walk to.
|
||||
- **Silencing a re-ring is the full dismiss sequence**: tag 1, the walk,
|
||||
tag 2, three questions. The ring screen's `_finishDismiss` sees a live
|
||||
session for today and resumes it with `reRings + 1`.
|
||||
- **A silent buzz 3 min before each deadline** (`SessionWarn.kt`, request
|
||||
code 1004), armed and cleared only with the re-ring.
|
||||
- **One re-ring per leg.** After a deadline re-ring is dismissed the leg is
|
||||
in `deadlineRung`; 5 min after its deadline (`bypassGrace`) it passes as
|
||||
`LegPass.bypassed` — the dead-tag hatch, paid for with one full ring.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
# Refactor TODO — enforce the 250-line file cap
|
||||
|
||||
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
|
||||
> and say "do refactor_claude_todo". It is self-contained: everything needed to
|
||||
> execute is below. Generated 2026-08-14 from a measured survey of every repo.
|
||||
|
||||
## Goal
|
||||
|
||||
Every file in this repo must be **at most 250 lines** — source, tests, and
|
||||
prose (`.md`/`.txt`/`.rst`/`.tex`) alike — and must **stay** that way forever,
|
||||
enforced by a gate that fails the commit, not by a note anyone can ignore.
|
||||
|
||||
Why: a file that cannot be read in one piece forces re-reads and partial edits,
|
||||
which is the single largest avoidable cost in an LLM-assisted workflow. Aim by
|
||||
churn, not size alone — refactoring pays where code is read and changed often
|
||||
(Fowler, _refactoring economic benefit_).
|
||||
|
||||
## Scope in this repo
|
||||
|
||||
- **18 files** currently exceed 250 lines (of 73 eligible files).
|
||||
- **6,553 lines** sit in violation; longest file is **478 lines**.
|
||||
|
||||
Exempt (do NOT split these):
|
||||
|
||||
- generated files — `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `**/l10n/generated/**`,
|
||||
anything with a `GENERATED` header
|
||||
- markup — `.html`, `.css`, `.scss`
|
||||
- data files — `.json`, `.yaml`, `.csv`, wordlists and other data-ish `.txt`
|
||||
(mean line length under 25 chars)
|
||||
|
||||
## Violations, highest ROI first
|
||||
|
||||
ROI = lines x commits in the last year. Work top-down; a long file nobody edits
|
||||
has near-zero payoff and should not be first.
|
||||
|
||||
| lines | commits/yr | kind | file |
|
||||
| ----: | ---------: | :--- | :------------------------------------- |
|
||||
| 478 | 15 | code | `wake_alarm/_alarm.py` |
|
||||
| 438 | 12 | code | `wake_alarm/tests/test_alarm_part2.py` |
|
||||
| 435 | 12 | code | `wake_alarm/tests/test_alarm.py` |
|
||||
| 358 | 8 | code | `wake_alarm/tests/test_alarm_part3.py` |
|
||||
| 457 | 4 | code | `wake_alarm/tests/test_alarm_sinks.py` |
|
||||
| 283 | 6 | code | `wake_alarm/tests/test_alarm_part4.py` |
|
||||
| 329 | 4 | code | `wake_alarm/_alarm_view.py` |
|
||||
| 405 | 3 | code | `wake_alarm/_audio.py` |
|
||||
| 386 | 3 | code | `wake_alarm/tests/test_sync.py` |
|
||||
| 371 | 3 | code | `wake_alarm/_sync.py` |
|
||||
| 317 | 3 | code | `wake_alarm/_escape_flow.py` |
|
||||
| 314 | 3 | code | `wake_alarm/tests/test_state.py` |
|
||||
| 420 | 2 | code | `wake_alarm/tests/test_alarm_audio.py` |
|
||||
| 351 | 2 | code | `wake_alarm/tests/test_smart_plug.py` |
|
||||
| 341 | 2 | code | `wake_alarm/_sinks.py` |
|
||||
|
||||
_(3 further files over 250 lines not listed — re-run the survey for the full set.)_
|
||||
|
||||
## How to split
|
||||
|
||||
- **Python** — extract cohesive helpers into sibling modules; keep the public
|
||||
API and imports stable.
|
||||
- **Shell** — split into `lib/*.sh` sourced by a thin entry script. Keep
|
||||
`set -euo pipefail` in each.
|
||||
- **Dart / TypeScript** — extract widgets/components into their own files.
|
||||
- **Tests** — split by test-group into sibling files
|
||||
(`foo.test.ts` -> `foo.parsing.test.ts`, `foo.render.test.ts`). Coverage must
|
||||
not drop.
|
||||
- **Docs** — split into topic files under `docs/` with an index. For an
|
||||
oversized `CLAUDE.md`, move detail into referenced docs so the
|
||||
always-loaded part shrinks.
|
||||
|
||||
**Do not** game the cap: no one-lining, no deleting tests, no moving code into
|
||||
an exempt extension, no `# noqa`-style suppressions.
|
||||
|
||||
## Make it permanent (required — this is the point)
|
||||
|
||||
A refactor without a gate silently regrows. Before this task is done:
|
||||
|
||||
1. Wire the shared gate `~/src/utils/scripts/check_file_length.sh` into this repo's
|
||||
`.pre-commit-config.yaml` as a local hook. If the repo has no pre-commit
|
||||
config, add a minimal one.
|
||||
2. The hook checks **files in the commit** (not the whole tree), so unrelated
|
||||
commits never break, and it **fails** — exit 1, not a warning.
|
||||
3. No baseline file and no allowlist. Those are suppressions.
|
||||
4. If this repo has CI (`.github/workflows`), add the same check there so it
|
||||
also fails on push.
|
||||
|
||||
## Done condition
|
||||
|
||||
- `bash ~/src/utils/scripts/check_file_length.sh --all` from this repo root exits 0.
|
||||
- The repo's own test suite and coverage bar are still green.
|
||||
- `pre-commit run --files <changed files>` passes.
|
||||
- A deliberately over-250-line test file, staged, makes `git commit` **fail**.
|
||||
- For a deployed daemon/app: the entry point still actually runs.
|
||||
|
||||
## Verify
|
||||
|
||||
Run the suite, then the alarm entry point. The phone_app companion needs `phone-deploy` verification if touched.
|
||||
|
||||
## Why this sat untouched (added 2026-08-24)
|
||||
|
||||
This repo ran a LOCAL `scripts/check_file_length.py` with `MAX_LINES = 500`,
|
||||
under a hook named "Max file length (500 lines)". It reported "gated" while
|
||||
permitting files every other repo rejects -- so the cap looked enforced and
|
||||
nothing ever failed. The hook now delegates to the shared 250-line gate in
|
||||
`~/src/utils`, which reports 17 real violations.
|
||||
|
||||
Done condition is now an exit code, not a count:
|
||||
|
||||
```
|
||||
bash scripts/check_file_length.sh --all # must exit 0
|
||||
```
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,225 +0,0 @@
|
||||
# Wake-alarm hardening — continue here
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Phases 0–7 are committed (`4286fcb`..`f5eb7fc`). This file is the entry point
|
||||
for the rest. **Full plan, with every measured finding:**
|
||||
`~/.claude/plans/wake-alarm-still-does-witty-toucan.md` — read it before
|
||||
starting; this file deliberately does not duplicate it.
|
||||
|
||||
## Done so far
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| 6 audio (HDMI silent) | **Done.** `_reactivate` wired into `WakeAlarm`; verified on the host — the HDMI sink appeared and became default. |
|
||||
| 7 speakers | `DOCS-speaker-recommendation.md`. Prose only, done. |
|
||||
| 9 refactor | `_alarm.py` 478→250. 12 new modules; see `DOCS-architecture.md`. |
|
||||
| 11 failsafe | `FAILSAFE_SILENCE_SECONDS=300`, audio only. Escalation re-tuned to fit inside it. |
|
||||
| 12 logging | JSONL + cross-process dedupe + derived correlation id. |
|
||||
| 14 fans | Gone: code, script, sudoers entry, installed state. Three stale doc/message references cleaned up in phase 6. |
|
||||
| 2 power button | **Done.** `_inhibit.py` (logind block, self-terminating, stale cleanup by pid via `/proc`) + `_alarm_session.py` fire-time record, re-fired by `_trigger` outside the catch-up window. |
|
||||
| 10 vmbox | **Done.** `scripts/vmbox/harness.sh` + `DOCS-vmbox.md`. 8 cases, fails closed. Found 2 live bugs the host could not reach. |
|
||||
| 5 curtains | **Done.** `_curtains.py` driver protocol + no-op default, called from `prepare_hardware()`. `DOCS-curtains-hardware.md` has the buy list. |
|
||||
| 1/4 phone alarm | **Done, verified on the phone.** Five Kotlin files behind one MethodChannel + the Dart ring screen. Fires from Doze on time, wakes the screen over the lockscreen, rings through DND on STREAM_ALARM, and dismisses via an NFC tap + challenge. Confirmed by the user on the Pixel 6a, not inferred. |
|
||||
| 3 room proofs | **Mostly done.** NFC works end to end: `NfcReader.kt` (hand-written), Settings → "Room proof" registers the tag, and a tap satisfies the gate. Steps+elapsed is the fallback. QR is specified, persisted and unit-tested but has **no scanner UI** — `mobile_scanner` is the droppable extra. |
|
||||
|
||||
## Remaining, in order
|
||||
|
||||
1. ~~QR scanner UI~~ — **cancelled 2026-08-30.** QR was deleted rather than
|
||||
finished: it had never been reachable at ring time (no scanner package, no
|
||||
CAMERA permission, `onQrScanned` had no caller), and a printed code is
|
||||
photographable, so completing it would have added a bypass to the very gate
|
||||
the same change was hardening. The dismiss path is now a strict sequence:
|
||||
tag 1 → 25 steps + 2 min → tag 2 → 3 challenges.
|
||||
2. **Item 8, mutual gating.** Phone half is now in place. This is the phase
|
||||
that legitimately touches `_sync.py`, so it carries that split (see below).
|
||||
3. **Item 13, black screen.** Different repo (`~/src/testsAndMisc/
|
||||
linux_configuration`), host-only, blocks nothing.
|
||||
|
||||
### Verified end to end on the Pixel 6a, 2026-08-27
|
||||
|
||||
**The whole path is confirmed on hardware, by the user, not inferred:**
|
||||
|
||||
- Fires from Doze on time (22:03:01 for a 22:03 arm), registered by the system
|
||||
as `RTC_WAKEUP` / "Next wake from idle".
|
||||
- Wakes the screen (`Asleep`→`Awake`) and puts `AlarmActivity` on top over the
|
||||
lockscreen, with the full-screen-intent notification posted.
|
||||
- **Rings through DND** — `zen_mode=1`, player `state:started` on
|
||||
`usage=USAGE_ALARM`/`STREAM_ALARM(4)`, `mutedState:none`. The STREAM_ALARM
|
||||
claim is measured, no longer assumed.
|
||||
- NFC tag registers (Settings → Room proof) and **a tap dismisses**:
|
||||
`tag discovered: 0465dab3ce2a81` on the ring screen, challenge solved,
|
||||
alarm silent.
|
||||
- Step counter reaches the gate.
|
||||
|
||||
**Four defects were found only because it was run on the phone**, every one
|
||||
invisible to the emulator (no NFC, no step sensor) and to the widget tests
|
||||
(which mock the channel):
|
||||
|
||||
1. `AlarmActivity.onPause` called `NfcReader.stop()`. `enableReaderMode` is
|
||||
per-activity but `disableReaderMode` is global, so anything briefly in
|
||||
front of the ring screen killed polling for the rest of the ring.
|
||||
2. `TYPE_STEP_COUNTER` is cumulative and on-change: it emits nothing until the
|
||||
user has already walked, and the first emission was being eaten as the
|
||||
baseline, so the screen sat at `0/25` forever. `TYPE_STEP_DETECTOR` is now
|
||||
primary — one event per stride, no baseline to miss.
|
||||
3. NFC does not poll while the ring shows over a locked keyguard
|
||||
(`mScreenState=ON_LOCKED`). `requestDismissKeyguard()` is called on fire.
|
||||
It does **not** silently unlock a secured phone — it raises the PIN prompt
|
||||
— so "tap without touching the phone" was never achievable on a secured
|
||||
device. Say that plainly; don't let docs imply otherwise.
|
||||
4. The **flash** challenge hides its code after 4s, which is unsolvable when
|
||||
the screen is reached by walking to another room. Removed from the phone's
|
||||
rotation, with a test asserting no self-hiding challenge is ever shown.
|
||||
|
||||
**A process lesson worth more than any of them:** after hardware confirmed the
|
||||
path worked, the next change bundled a MethodChannel restructure with a bug
|
||||
fix and regressed both NFC and steps. The user caught it. When hardware has
|
||||
just gone green, make the smallest possible delta on top of *that exact state*
|
||||
— never a restructure and a fix together.
|
||||
|
||||
### Fully automatic, verified on the Pixel 2026-08-27
|
||||
|
||||
The alarm is no longer opt-in and can no longer be weakened:
|
||||
|
||||
- **Arms itself with zero taps.** Launching the app was enough —
|
||||
`dumpsys alarm` showed `RTC_WAKEUP ... com.kuhy.wake_alarm_sync/.AlarmReceiver`
|
||||
at `2026-08-28 08:00:00`, `window=0`, `exactAllowReason=policy_permission`.
|
||||
- **08:00 daily is baked in** (`AlarmPolicy.baselineHour`, mirrored by the PC's
|
||||
`FALLBACK_ALARM_HOUR`). There is no arm button and no cancel — the Kotlin
|
||||
`cancel` handler is deleted, so the capability does not exist rather than
|
||||
being one `invokeMethod` away.
|
||||
- **Earlier-only ratchet, confirmed on-device.** Picking a later time was
|
||||
rejected on the phone with "That is not earlier than 08:00. The alarm can
|
||||
only be moved earlier.", and the armed alarm stayed at 08:00. An earlier
|
||||
start is one-shot and resets after the alarm fires.
|
||||
- **Daily watchdog** (`AlarmWatchdog`) re-arms if nothing is scheduled, and
|
||||
`BootReceiver` re-establishes it because `setInexactRepeating` does not
|
||||
survive a reboot.
|
||||
- **PC watchdog:** `_trigger._clamp_to_baseline()` refuses any resolved alarm
|
||||
later than 08:00 whatever its source, so a stale or tampered remote value
|
||||
cannot move the alarm later through sync.
|
||||
|
||||
### DND: verified for priority mode, NOT for total silence
|
||||
|
||||
Measured on the Pixel: `STREAM_ALARM` is unmuted under **priority** DND
|
||||
(`zen_mode=1`, mask `0x1a6`) and **muted** under **total silence**
|
||||
(`zen_mode=2`, mask `0x9be`). The confirmed ring was priority DND. No audio
|
||||
flag escapes total silence, so if that gap matters the fix is a zen-policy
|
||||
check at arm time, not an audio change. Detail in `DOCS-phone-alarm.md`.
|
||||
|
||||
Two harness traps found while testing this, worth keeping:
|
||||
|
||||
- **Do not pass `--build-number` to a probe build** — it produced a
|
||||
non-running APK.
|
||||
- **The arm probe can register for TOMORROW.** It calls `schedule()`, then
|
||||
`main.dart`'s `rearm()` recomputes `nextOccurrence`; if the armed minute has
|
||||
passed by then the alarm lands on the next day. Always re-check the
|
||||
registered `triggerTime` is today before waiting for a ring.
|
||||
|
||||
### Still unverified
|
||||
|
||||
- The **two-tag sequence** has not been walked end to end on the phone: only
|
||||
one physical tag is registered, so every on-device run so far has fallen
|
||||
through to the 5-minute bypass. Needs a second tag.
|
||||
- The **locked-keyguard** dismiss was not run to completion: the confirmed run
|
||||
was on an unlocked phone. The PIN-prompt path is implemented but unproven.
|
||||
|
||||
## Decisions already made — do not re-litigate
|
||||
|
||||
- Failsafe stops **sound only**; lock, challenge and workout gate persist.
|
||||
- Gating is **phone first, then PC**, with a bounded escape: if sync is
|
||||
unreachable for ~600 s the PC gate degrades to locally-solvable, logged
|
||||
WARNING. Without that, a Firebase outage means you can neither dismiss nor
|
||||
get to work.
|
||||
- Room proof: NFC **or** QR qualifies. **Steps alone does not** — the phone is
|
||||
stationary in the other room while you walk *to* it, so steps only accrue
|
||||
after pickup and can be faked by shaking it in bed. Steps + elapsed time is
|
||||
the fallback when both others fail. (I narrowed "any one satisfies" here;
|
||||
say if you disagree.)
|
||||
- Phone alarm: hand-written Kotlin behind one MethodChannel, **not** a plugin.
|
||||
AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that
|
||||
won't compile breaks the build outright. `STREAM_ALARM` is load-bearing —
|
||||
it is what ignores DND and silent mode.
|
||||
- Curtains: no hardware; pluggable driver + logging no-op default.
|
||||
- Quality bar: 100% branch coverage, no new `# noqa` / `type: ignore`.
|
||||
|
||||
## Traps found in the phone session
|
||||
|
||||
- **The plan's `LogStore.changes` does not exist.** `crdt_sync_dart` has no
|
||||
Stream at all. Re-arm therefore needs three explicit hooks — startup,
|
||||
BOOT_COMPLETED, dismiss — and dropping any one makes the alarm ring once
|
||||
and then silently stop forever.
|
||||
- **The Android SDK was never missing, only unexported.** `~/sdk/Android/Sdk` has
|
||||
`cmdline-tools`, `emulator` and `platform-tools`; only `adb` was on PATH.
|
||||
Export `ANDROID_HOME` rather than installing anything.
|
||||
- **A permission declared in the manifest is not a permission granted.**
|
||||
Both `POST_NOTIFICATIONS` and `ACTIVITY_RECOGNITION` are runtime grants, and
|
||||
each one missing silently breaks a different half of the dismiss path
|
||||
(no full-screen intent / no step sensor). The AVD caught both because a
|
||||
freshly installed app there sits at `importance=NONE`.
|
||||
- **A debug probe entrypoint with its own UI proves nothing about the route.**
|
||||
The first probe rendered its own widget, so the AVD run validated the Kotlin
|
||||
and nothing about `/ring`. Point the probe at the real app widget.
|
||||
|
||||
## Traps that cost time this session
|
||||
|
||||
- **The 250-line gate fires on *staged* files.** Touching a file means
|
||||
splitting it in the same commit. Still over (re-measured 2026-08-27, after
|
||||
phase 6): `test_sync.py` 386, `_sync.py` 371, `test_smart_plug.py` 351,
|
||||
`_sinks.py` 341, `_alarm_view.py` 329, `_escape_flow.py` 317,
|
||||
`test_state.py` 314. Budget for it. `test_trigger.py` is now split and
|
||||
under.
|
||||
- **`morning_routine/_orchestrator.py:39` hardcodes `"wake_alarm._alarm"`**
|
||||
(different repo, `~/src/testsAndMisc`). Any module-path change updates it in the
|
||||
*same* commit. This exact omission caused a month-long silent outage. Keep
|
||||
`main()` in `_alarm.py`.
|
||||
- **Splitting a test file loses its fixtures.** `_make_mock_tk`,
|
||||
`_block_real_tk` and `_block_extra_devices` live *after* the imports, so a
|
||||
naive header-copy drops them and every test errors. Copy the whole region
|
||||
before the first `class`. Also: a multi-line module docstring gets stranded
|
||||
if you replace only its first line.
|
||||
- **Moving a symbol breaks `patch("wake_alarm._alarm.X")` silently** — the
|
||||
patch stops intercepting and the test asserts nothing. Grep the whole
|
||||
`tests/` tree for the old target after any move.
|
||||
- **Autoformat strips an import added before its first usage.** `struct`
|
||||
vanished from the extracted `_tone.py`; ruff, mypy and pylint all passed.
|
||||
Only running it caught the `NameError`. Add import and usage in one edit.
|
||||
- **Never let a beep-loop test miss its rung.** A test that patches only one
|
||||
of `_beep_soft/_medium/_loud` and waits for it will spin forever on the real
|
||||
audio stack and hang the whole suite instead of failing. Patch all three.
|
||||
- Run tests with `/usr/bin/python3`, not the dev venv (no `gatelock` there).
|
||||
- **`vm run` does not propagate the guest command's exit code** — it reports
|
||||
the VM's verdict. A gate built on it fails *open*. See `DOCS-vmbox.md`.
|
||||
- **`_state.py`'s UTC `_today_str()` must NOT be flipped to local**, despite
|
||||
what the plan says. `screen_locker/_wake_state.py` mirrors the helper and
|
||||
reads the same `wake_state.json`; flipping it loses an earned workout skip
|
||||
for any dismissal between 00:00 and 02:00 local. `_alarm_session.py` shows
|
||||
the way around it: carry an aware `fired_at`, compare elapsed time, no date
|
||||
key at all.
|
||||
- **Do not split a file the current change does not already have to touch.**
|
||||
The gate fires on *staged* files, so an oversized file only becomes your
|
||||
problem once you edit it. Editing `_sync.py` merely to repoint an import
|
||||
pulled its 371 lines into the changeset and cascaded into five new modules
|
||||
and a broken tree before being reverted. Prefer putting a new constant in
|
||||
its single consumer (as `_curtains.py` does) over touching `_constants.py`.
|
||||
- **A `__main__` CLI extracted to a new module breaks its documented command
|
||||
silently.** `python -m wake_alarm._sync --show` printed nothing and exited
|
||||
0 — invisible to ruff, mypy, pylint and the suite alike. If a later phase
|
||||
moves that CLI, run the documented command and check the *output*.
|
||||
- **Ruff's autofix will gut a function** whose results are only printed —
|
||||
it stripped both the assignments and the `print` from a vmbox check, and
|
||||
every linter stayed green. Grep the file for its output call after any
|
||||
autofix.
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
/usr/bin/python3 -m pytest wake_alarm/tests/ -q # 455 pass, 100% cov
|
||||
bash scripts/vmbox/harness.sh # 8/8 cases, exit 0
|
||||
pre-commit run --files <changed>
|
||||
/usr/bin/python3 -m wake_alarm._trigger --dry-run
|
||||
/usr/bin/python3 -m wake_alarm._alarm --demo # real window; ask first
|
||||
```
|
||||
|
||||
Done = every item above shipped, this file deleted.
|
||||
@ -1,63 +0,0 @@
|
||||
# Hardware ideas: defeat a fully-powered-off phone
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Both items below were scoped during the 2026-09-23 workday-stick session
|
||||
(`DOCS-morning-session-pc.md`) and explicitly deferred out of scope for that
|
||||
session: no existing firmware project and no owned wearable, both need a real
|
||||
purchase before any code gets written. Written here instead of
|
||||
`~/src/todo/BACKLOG.md` because that file is gitignored/export-only from the
|
||||
todo app and a manual edit there is silently lost on the next export — this
|
||||
is the durable copy.
|
||||
|
||||
## Item 1 — second wake-alarm device: rings independently of the phone's power state
|
||||
|
||||
### what
|
||||
An ESP32 + buzzer that polls the same Firebase alarm/session record
|
||||
wake-alarm already reads and keeps ringing until it sees a dismiss or a
|
||||
started session — so powering the phone off can no longer silently kill the
|
||||
whole wake attempt the way it does today.
|
||||
|
||||
### where
|
||||
new repo (no existing ESP32/PlatformIO/Arduino project anywhere in `~/src`
|
||||
to build on — checked). Firebase-only, per the "new syncing apps are
|
||||
Firebase-only" rule.
|
||||
|
||||
### must
|
||||
- must not require the phone to relay anything — the whole point is
|
||||
surviving the phone being off.
|
||||
- optional: a physical snooze/dismiss button, since the phone's own dismiss
|
||||
gate isn't reachable from the device.
|
||||
|
||||
### done
|
||||
On a Tue/Wed/Thu, power the phone off right at the alarm and the ESP32
|
||||
still rings, and keeps ringing, until the phone's session shows a start.
|
||||
|
||||
### verify
|
||||
Bench test first (USB power, no case), then a real overnight run.
|
||||
|
||||
### read first
|
||||
- `DOCS-phone-alarm.md` (the Firebase alarm schema to poll)
|
||||
- `DOCS-morning-session-pc.md` (why this exists: the workday-stick project,
|
||||
2026-09-23)
|
||||
|
||||
## Item 2 — wearable wake-defeat check
|
||||
|
||||
### what
|
||||
A cheap fitness band whose own sleep log (stored on the band, synced later)
|
||||
can show "asleep again after the alarm rang" even when the phone was fully
|
||||
off and missed the whole thing.
|
||||
|
||||
### where
|
||||
new app or an extension of `phone_app/`, once a device is picked.
|
||||
|
||||
### must
|
||||
- must not: buy or integrate anything before checking Gadgetbridge's
|
||||
current supported-device list — research first, kuhy owns no wearable and
|
||||
none is integrated anywhere in `~/src` today (checked).
|
||||
|
||||
### done
|
||||
Not yet scoped — picking a supported device is the blocking step.
|
||||
|
||||
### verify
|
||||
Blank — desktop, once there's something to verify.
|
||||
@ -1,90 +0,0 @@
|
||||
# Wake enforcement v2 — follow-ups for the next session
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Handoff from the 2026-10-01 session that built v2. Read
|
||||
`DOCS-wake-enforcement.md` (the contract + verification status) and
|
||||
`AGENTS.md` first. Start **after the morning of 2026-10-02** so there is real
|
||||
evidence to check.
|
||||
|
||||
## Prompt to paste into the new session
|
||||
|
||||
> Work through ~/src/wake-alarm/TODO-wake-enforcement-v2-followups.md top to
|
||||
> bottom. Verify each item yourself on the real target (phone 23181JEGR08034,
|
||||
> this PC) before calling it done, then delete the TODO file in the final
|
||||
> commit.
|
||||
|
||||
## 1. Check the first real morning (2026-10-02, Fri, carrot day)
|
||||
|
||||
- `python -m wake_alarm._audit 2026-10-02` (and the 11:05 file in
|
||||
`~/data/wake-alarm/incidents/`). Expected: phone `alarm_fired`
|
||||
(`reason=daily`), `ring_started_published` with `source=alarm` (headless
|
||||
engine, release build — never verified in release before) or
|
||||
`ring_screen`, `stick_check` verdict for a non-stick day, no fallback ring
|
||||
unless the phone really was silent.
|
||||
- `adb logcat` for `WakeAlarmHeadless` / `WakeAlarmStick*` if anything is
|
||||
missing. Firebase fields `ring_started`, `phone_events` must be non-empty
|
||||
(`enforcement_view(fetch_firebase_logs())`).
|
||||
- Any MISMATCH in the report is a bug to fix, not a note.
|
||||
|
||||
## 2. focus-owner reacting to a real lockdown (needs kuhy's OK + timing)
|
||||
|
||||
Never verified on the Pixel. Either wait for the first real Tue/Wed/Thu miss
|
||||
and read focus-owner's Debug log (`LOCKDOWN_ACTIVATED` with the through-date,
|
||||
then a `WORKDAY_LOCKDOWN` pass), or — with kuhy's explicit OK, at a time he
|
||||
names — trigger one and lift it with the escape button (logs an escape).
|
||||
|
||||
## 3. Root ring vs a card held busy by PipeWire
|
||||
|
||||
Host test 2026-10-01: `wake-alarm-ring-alsa.service` got `Device or resource
|
||||
busy` while the user's PipeWire held card `Generic_1` (ALC897). kuhy approved
|
||||
killing the holder (`fuser -k -TERM /dev/snd/pcmC<N>D0p` after 3 busy opens,
|
||||
in `scripts/pc/ring_alsa.sh`), but the auto-mode classifier refused the edit
|
||||
twice. **kuhy must add a permission rule first**, or choose the non-killing
|
||||
alternative: when the root unit cannot open the card, `_ring_player` falls
|
||||
back to `PipeWireBackend` (PipeWire holds the card, so it can mix the ring
|
||||
in). Add a vmbox case that holds `snd-dummy` open and proves the ring still
|
||||
plays.
|
||||
|
||||
## 4. S5 (poweroff) RTC wake test on the host
|
||||
|
||||
Hibernate (S4) wake is verified and is the default mode. S5 is not. The
|
||||
self-resume boot unit (`~/data/claude-scratch/wake-fw/s5_after_boot.sh`,
|
||||
never installed) was refused by the classifier. Simplest: with kuhy present,
|
||||
`sudo -n rtcwake -m no -s 240` + record the epoch, `systemctl poweroff`; kuhy
|
||||
powers on if it does not wake; then compare `uptime -s` with the epoch. If S5
|
||||
works, decide with kuhy whether to switch `pc_wake.json` to `poweroff` (covers
|
||||
a typed `shutdown`, not just the power key).
|
||||
|
||||
## 5. utils dependency upgrade — prepared, uncommitted
|
||||
|
||||
In `~/src/utils` (do NOT stage the other session's `ts_config/README.md`,
|
||||
`scripts/lib/alias-imports.mjs`, `test/alias-imports.test.js`): pylint 4.1.1
|
||||
×3, typescript-eslint 8.71.0 (ts_config/ts_core/web_ui), @eslint-react
|
||||
5.23.3, eslint-plugin-sonarjs 4.2.2, pnpm 12.8.1 (+ lockfiles,
|
||||
`ts_config/pnpm-workspace.yaml`), allowlist: typescript-eslint entry removed,
|
||||
typescript entry now `blocked_by: transitive:typescript-eslint@8.71.0`.
|
||||
Lint + tests pass in all three. `finish_auto.sh` fails at its test step with
|
||||
corepack `MODULE_NOT_FOUND .../pnpm/12.8.1/bin/pnpm.cjs` (pnpm 12.8 ships a
|
||||
native binary; corepack 0.36 can't launch it). Global pnpm 12.8.1 is now
|
||||
installed directly (`corepack disable pnpm`); find what in the gate still
|
||||
calls corepack (read `~/data/claude-scratch/wake-v2/utils-commit.log`), fix,
|
||||
commit, watch CI green.
|
||||
|
||||
## 6. Fleet repos still pinning typescript-eslint 8.70.0
|
||||
|
||||
The allowlist entry that excused them is gone, so their freshness gate turns
|
||||
red on the next run: konbini-67, iron-and-anvil, awesome-mcp-explorer,
|
||||
europe-county-map and any other repo pinning it (`grep -rl
|
||||
'"typescript-eslint": "8.70.0"' ~/src --include=package.json`). Bump each to
|
||||
8.71.0, lint + test, commit, CI green.
|
||||
|
||||
## 7. Small leftovers
|
||||
|
||||
- `~/src/todo`: `cupertino_icons` 1.0.9 → 2.0.0 — another session had
|
||||
uncommitted work there; only bump once its tree is clean.
|
||||
- Global `~/.claude/CLAUDE.md` sandbox-first note says the vmbox guest is
|
||||
BIOS-only; vmbox has `--uefi` and `vm wake` (RTC-wake emulation) since
|
||||
utils 694c013. Ask kuhy before editing that file.
|
||||
- Clear the test data left in the `wake_alarm_api36` emulator (escape for
|
||||
10-01, ringStarted, stick state) before it is ever signed into sync.
|
||||
13
install.sh
@ -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
@ -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 |
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.6 KiB |
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
@ -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 |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 34 KiB |
@ -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 |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
@ -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 |
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
88
phone_app/lib/screens/day_cell.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
106
phone_app/lib/screens/home_tiles.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)!)],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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')}';
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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()),
|
||||
),
|
||||
|
||||
197
phone_app/lib/screens/sick_day_sheet.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
135
phone_app/lib/screens/stakes_marks.dart
Normal 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)],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
59
phone_app/lib/services/alarm_sync_e2e.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
);
|
||||
|
||||
72
phone_app/lib/services/data_export.dart
Normal file
@ -0,0 +1,72 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
|
||||
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||
|
||||
/// One crdt log the export covers.
|
||||
typedef LogSlot = ({
|
||||
Log Function() snapshot,
|
||||
Future<void> Function(Log merged) replaceAll,
|
||||
});
|
||||
|
||||
/// Marks the file as this app's export.
|
||||
const exportFormat = 'wake_alarm_export';
|
||||
|
||||
/// Shape version.
|
||||
const exportVersion = 1;
|
||||
|
||||
/// Every slot's log plus the ramp dates as JSON; no device id or secrets.
|
||||
Future<String> buildExport(Map<String, LogSlot> slots, {DateTime? now}) async {
|
||||
final dates = (await RampProgress.load()).toList()..sort();
|
||||
return const JsonEncoder.withIndent(' ').convert(
|
||||
buildEnvelope(
|
||||
format: exportFormat,
|
||||
version: exportVersion,
|
||||
now: now ?? DateTime.now(),
|
||||
body: <String, Object?>{
|
||||
'logs': <String, Object?>{
|
||||
for (final entry in slots.entries)
|
||||
entry.key: jsonDecode(logToJson(entry.value.snapshot())),
|
||||
},
|
||||
'ramp_earned_dates': dates,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Merges via [mergeLogs] only, after validating all; throws [FormatException].
|
||||
Future<void> importExport(String text, Map<String, LogSlot> slots) async {
|
||||
final parsed = _parse(text);
|
||||
for (final entry in parsed.logs.entries) {
|
||||
final slot = slots[entry.key];
|
||||
if (slot == null) continue;
|
||||
await slot.replaceAll(mergeLogs(slot.snapshot(), entry.value));
|
||||
}
|
||||
await RampProgress.merge(parsed.rampDates);
|
||||
}
|
||||
|
||||
typedef _Parsed = ({Map<String, Log> logs, List<String> rampDates});
|
||||
|
||||
_Parsed _parse(String text) {
|
||||
final decoded = parseEnvelope(
|
||||
text,
|
||||
format: exportFormat,
|
||||
version: exportVersion,
|
||||
);
|
||||
final rawLogs = decoded['logs'];
|
||||
if (rawLogs is! Map<String, dynamic>) {
|
||||
throw const FormatException('export has no "logs" object');
|
||||
}
|
||||
final logs = <String, Log>{};
|
||||
for (final entry in rawLogs.entries) {
|
||||
try {
|
||||
logs[entry.key] = logFromJson(jsonEncode(entry.value));
|
||||
} on Object catch (error) {
|
||||
throw FormatException('log "${entry.key}" is malformed ($error)');
|
||||
}
|
||||
}
|
||||
final rawDates = decoded['ramp_earned_dates'];
|
||||
final dates = rawDates is List ? rawDates.whereType<String>() : <String>[];
|
||||
return (logs: logs, rampDates: dates.toList());
|
||||
}
|
||||
38
phone_app/lib/services/data_transfer.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
import 'package:wake_alarm_sync/services/data_export.dart';
|
||||
import 'package:wake_alarm_sync/services/free_day_pool.dart';
|
||||
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
|
||||
|
||||
/// The three logs; pass the live [alarm] so no stale twin overwrites it.
|
||||
Future<Map<String, LogSlot>> openLogSlots({AlarmSyncService? alarm}) async {
|
||||
final alarmStore = alarm ?? await AlarmSyncService.open();
|
||||
final free = await FreeDayPool.open();
|
||||
final sick = await SickDayPool.open();
|
||||
return {
|
||||
'alarm': (snapshot: alarmStore.snapshot, replaceAll: alarmStore.replaceAll),
|
||||
'free_days': (snapshot: free.snapshot, replaceAll: free.replaceAll),
|
||||
'sick_days': (snapshot: sick.snapshot, replaceAll: sick.replaceAll),
|
||||
};
|
||||
}
|
||||
|
||||
/// Saves the export to device storage via the system Save-as picker.
|
||||
///
|
||||
/// False when the picker was cancelled.
|
||||
Future<bool> saveExport({AlarmSyncService? alarm}) async {
|
||||
final now = DateTime.now();
|
||||
final json = await buildExport(await openLogSlots(alarm: alarm), now: now);
|
||||
final day = now.toIso8601String().substring(0, 10);
|
||||
return await exportJsonText(
|
||||
fileName: 'wake-alarm-export-$day.json',
|
||||
text: json,
|
||||
);
|
||||
}
|
||||
|
||||
/// Picks an export and merges it; false when the picker was cancelled.
|
||||
Future<bool> pickAndImport({AlarmSyncService? alarm}) async {
|
||||
final text = await pickJsonText();
|
||||
if (text == null) return false;
|
||||
await importExport(text, await openLogSlots(alarm: alarm));
|
||||
return true;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
/// Platform gate for the programmatic Google sign-in flow.
|
||||
///
|
||||
/// Conditional export because the answer differs per platform *and* because
|
||||
/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws
|
||||
/// `UnimplementedError` where no implementation is registered -- an `Error`,
|
||||
/// not an `Exception`, so it escapes an ordinary catch and would take down
|
||||
/// the settings screen's `build()`.
|
||||
library;
|
||||
|
||||
export 'google_platform_io.dart'
|
||||
if (dart.library.js_interop) 'google_platform_web.dart';
|
||||
@ -1,10 +0,0 @@
|
||||
/// Whether the programmatic Google sign-in flow exists on this platform.
|
||||
///
|
||||
/// The io half: Android has the Credential Manager flow; Linux desktop does
|
||||
/// not, and this app's desktop build is the web one anyway.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
/// True only on Android, the one platform shipping the programmatic flow.
|
||||
bool get googleSignInSupported => Platform.isAndroid;
|
||||
@ -1,8 +0,0 @@
|
||||
/// Whether the programmatic Google sign-in flow exists on this platform.
|
||||
///
|
||||
/// The web half: Google Identity Services signs in only through its own
|
||||
/// rendered button, so `authenticate()` throws `UnimplementedError` here.
|
||||
library;
|
||||
|
||||
/// Always false: the web plugin has no programmatic sign-in.
|
||||
bool get googleSignInSupported => false;
|
||||
@ -2,9 +2,9 @@
|
||||
///
|
||||
/// Why this is its own file: `crdt_sync` is deliberately pure Dart (only
|
||||
/// `crypto` and `http`), because the same library runs on Linux desktop and
|
||||
/// headless under systemd, where `google_sign_in` does not exist. So the
|
||||
/// plugin lives here, in the app, and the library only ever receives a token
|
||||
/// string through a closure.
|
||||
/// headless under systemd, where Android's AccountManager does not exist. So
|
||||
/// the plugin call lives here, in the app, and the library only ever receives
|
||||
/// a token string through a closure.
|
||||
///
|
||||
/// What this buys: on a fresh install the sync account is reached by tapping
|
||||
/// one account-picker row instead of typing a long password on a phone
|
||||
@ -12,10 +12,7 @@
|
||||
/// credential for desktop and the Python side.
|
||||
library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:wake_alarm_sync/services/google_platform.dart' as platform_gate;
|
||||
import 'package:google_account_token/google_account_token.dart' as plugin;
|
||||
|
||||
/// The account the security rules pin.
|
||||
///
|
||||
@ -50,73 +47,26 @@ const kServerClientId =
|
||||
|
||||
/// Whether this platform can sign in programmatically.
|
||||
///
|
||||
/// False on web, where Google Identity Services only signs in through its own
|
||||
/// rendered button: `authenticate()` throws `UnimplementedError` there. The
|
||||
/// desktop build of this app *is* the web build, so without this check the
|
||||
/// "Sign in with Google" button would crash the settings screen on desktop
|
||||
/// rather than falling back; see `google_platform.dart` for why this is a
|
||||
/// platform check rather than a question asked of the plugin.
|
||||
/// True only on Android (the plugin's own gate): the desktop build of this
|
||||
/// app *is* the web build, and neither has an AccountManager, so without this
|
||||
/// check the "Sign in with Google" button would show a flow that can never
|
||||
/// succeed instead of falling back to the password path.
|
||||
///
|
||||
/// Also false when no client id is compiled in: a button that cannot possibly
|
||||
/// succeed is worse than no button, and without this check an unconfigured
|
||||
/// build reports "cancelled" and sends you debugging the OAuth console for a
|
||||
/// problem that is really a missing constant.
|
||||
/// succeed is worse than no button.
|
||||
bool get googleSignInSupported =>
|
||||
platform_gate.googleSignInSupported && kServerClientId.isNotEmpty;
|
||||
plugin.googleAccountTokenSupported && kServerClientId.isNotEmpty;
|
||||
|
||||
/// Returns a Google ID token for the signed-in account, or null.
|
||||
///
|
||||
/// Null rather than throwing when the user dismisses the picker: cancelling is
|
||||
/// an ordinary outcome, and the caller falls back to the password path.
|
||||
///
|
||||
/// [signInFn] and [serverClientId] exist for tests, which cannot reach the
|
||||
/// platform channel the plugin uses.
|
||||
/// [signInFn] and [serverClientId] exist for tests that bypass the plugin.
|
||||
Future<String?> googleIdToken({
|
||||
Future<String?> Function()? signInFn,
|
||||
String serverClientId = kServerClientId,
|
||||
}) async {
|
||||
if (signInFn != null) return await signInFn();
|
||||
if (serverClientId.isEmpty) {
|
||||
// A build without --dart-define=GOOGLE_SERVER_CLIENT_ID would otherwise
|
||||
// fail inside the plugin with a far less obvious message.
|
||||
log(
|
||||
'Google sign-in unavailable: GOOGLE_SERVER_CLIENT_ID was not set at '
|
||||
'build time; falling back to the password path',
|
||||
level: 900,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// The plugin reaches the OS account picker through a platform channel,
|
||||
// which `flutter test` has no binding for -- the same reason main.dart and
|
||||
// firebase_backend.dart's keystore adapters are excluded. Everything above
|
||||
// this point is pure Dart and is covered.
|
||||
// coverage:ignore-start
|
||||
try {
|
||||
await GoogleSignIn.instance.initialize(serverClientId: serverClientId);
|
||||
if (!GoogleSignIn.instance.supportsAuthenticate()) {
|
||||
// Web/desktop: sign-in must go through the rendered GIS button, which
|
||||
// this app does not host yet. Fall back rather than throwing.
|
||||
log(
|
||||
'Google sign-in is not available on this platform; falling back to '
|
||||
'the password path',
|
||||
level: 900,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final account = await GoogleSignIn.instance.authenticate();
|
||||
return account.authentication.idToken;
|
||||
} on GoogleSignInException catch (error, stackTrace) {
|
||||
// Includes the user simply dismissing the picker, which is not an error
|
||||
// worth surfacing -- but log it, because a *configuration* failure
|
||||
// (unregistered SHA-1, wrong client id) arrives through the same path and
|
||||
// is otherwise indistinguishable from "the user changed their mind".
|
||||
log(
|
||||
'Google sign-in did not complete',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// coverage:ignore-end
|
||||
return await plugin.googleIdToken(serverClientId: serverClientId);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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: '');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
82
phone_app/lib/services/sick_day_keep.dart
Normal 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();
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
91
phone_app/lib/services/stakes_view.dart
Normal 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;
|
||||
}
|
||||
@ -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]:
|
||||
|
||||