Commit Graph

11 Commits

Author SHA1 Message Date
34037e55c1 phone: sick days — picker, silent ring, stick stand-down, pre-alarm sync
- Sick days screen (own name, sick icon, cat-2 fill): tap today or a later
  day to mark it sick, tap again to clear; past days inert. Writes the
  sickdays wire format (state sick/cleared + actor, one HLC per write) to
  sickdays-sync/devices via crdt_sync syncLog.
- Mutual exclusion in both pickers: each syncs the other pool on open and
  refuses a date it holds, naming the pool; an unreadable pool refuses all.
- Native SickDays cache (fail closed via DayCache); AlarmReceiver skips the
  ring with reason "sick_day" (no markFired, no ring_started); StickCheck
  verdict sick_day, streak skips sick days; stakes card says so.
- Pre-alarm pool sync: allow-while-idle alarm at 21:00 and A-30 (request
  code 1006) runs a headless engine (poolSyncMain) that pulls both pools
  from RTDB and rewrites both native caches, so a PC mark arrives with the
  app closed. The ring decision stays a cache read.
- JVM unit tests (JUnit 4.13.2) for DayCache, RingDecision,
  PoolSyncSchedule, StickCheck; Dart tests; Dart->Python interop check.

pylint hook skipped: verify.py imports sickdays, not in the hook env until
the sickdays-v0.1.0 tag exists; run by hand in a sickdays venv (10.00/10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:22:40 +02:00
d8b07b2be4 Cook the frog: lower the wake baseline a minute a day from 08:00 to 07:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 13:15:44 +02:00
bfe8bab58a Add the Tue/Wed/Thu workday stick: signal focus-owner and write tomorrow's penalty
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 21:20:58 +02:00
af293413c0 Keep the morning session reachable after leaving the app; drop the still-phone watch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:01:22 +02:00
f31f195187 Keep the morning going after the ring: weigh-in, shower, dress, desk, or it rings again
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:35:40 +02:00
146c427d6d Add the free-day picker, and stop the phone ringing on a free day
The shared pool (~/utils/freedays) was CLI-only: the gates on the PC stood
down, but the phone kept ringing at 08:00 and there was nowhere to mark a
day from bed. This is the one picker for every app.

FreeDayPool is a Dart port of the Python rules with the identical record
shape (id = ISO date; state/consumed/actor/reason on one tick; un-mark is
a state flip, never a tombstone), verified by a real cross-language round
trip: a log written here reads back through freedays.is_free_day and
remaining() with the same verdicts and count, and the PC's live pool reads
here as the same 27 of 35 that `freedays status` prints.

The alarm fires from Kotlin with no engine running, so the pool is
mirrored into a native SharedPreferences set after every mark, release and
sync (channel method setFreeDays), and AlarmReceiver consults it before
starting AlarmService. Fail closed: a missing or corrupt cache rings. All
three branches exercised on the API 36 AVD by broadcasting the receiver.

Firebase only for this namespace -- no GitHub mirror; the alarm record
keeps its own because un-migrated installs depend on it.

Known gap, documented rather than papered over: a day marked on the PC
reaches the phone at the next app launch or picker open, not overnight.
The ring path takes no background dependency by design, so mark on the
phone, or open the app once after marking on the PC.

Verified on the phone (release build, 23181JEGR08034): picker shows the
budget, marks 2026-09-30 with a reason, pushes it to
freedays-sync/devices/<uuid>, releases it with a full refund back to 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
2026-09-11 13:45:42 +02:00
7523cb4225 Ring the away profile when the phone is not at home
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:45:11 +02:00
95be57b575 Make the alarm fully automatic and un-weakenable (08:00, earlier-only)
The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to
PC", so it could be defeated by simply not tapping it, or by cancelling it the
night before. It is now automatic and there is no lever a half-asleep user can
pull that buys them sleep.

- AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI
  offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or
  after the baseline, and refuses to relax an active override. Earlier-only is
  the whole design: every available change costs the user sleep.
- The ratchet resets after each fire, so an early start is one-shot. A one-way
  ratchet with no reset is a trap, not a defence — the first 05:00 would
  otherwise be permanent with no way back short of reinstalling.
- load() re-validates the stored value and ignores anything at or after the
  baseline. SharedPreferences is not a trust boundary.
- No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the
  capability genuinely does not exist rather than being one invokeMethod away.
- Arming happens in main()'s init seam and via AlarmWatchdog, a daily
  self-rescheduling check that re-arms if nothing is scheduled — closing the
  gap where a force-stop or a failed re-arm goes unnoticed until the morning
  it does not ring. BootReceiver re-establishes it, since setInexactRepeating
  does not survive a reboot.
- Publishing to the PC is automatic too; it used to need the same tap.

PC half, so the two agree with the network down:
- FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a
  Firebase outage silently moved the alarm five hours LATER — the one
  direction the design refuses to allow.
- _trigger._clamp_to_baseline() refuses any resolved alarm later than the
  baseline, whatever its source. Without it the PC would faithfully reproduce
  a stale or tampered remote value and the forbidden direction stays reachable
  through sync.

Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions
are covered, per the standing rule about monotonic counters. test_sync.py and
test_trigger.py were split (payload, token, CLI, clamp) to stay under the
250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm,
which now have direct tests instead.

`python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and
still print real output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 23:03:00 +02:00
dc2d54668b Phase 4/3: close the ring screen on dismiss, and make NFC registerable
Two defects that each defeated the alarm in a different way:

1. Nothing finished AlarmActivity after a correct answer. Sound stopped and
   the alarm re-armed, but the dismiss screen stayed up over the lockscreen
   forever — back is disabled in both halves by design — and the live instance
   kept _inRoom=true with the solved answer still typed, so the next ring
   could reuse an already-passed gate. AlarmActivity now handles
   closeRingScreen; the shared AlarmChannel deliberately holds
   applicationContext and cannot finish an activity, hence the split handler.

2. Nothing could write roomProof.nfcTagId — there was no registration UI — so
   checkNfc always answered "no tag registered" and checkQr was never called.
   Functionally that left steps as the only satisfiable proof, which is
   exactly the state the decision rules out ("NFC or QR qualifies; steps alone
   does NOT") and removes the redundancy whose purpose is that one failing
   mechanism at 06:00 must never block dismissal. Added RoomProofScreen
   (reader mode from MainActivity, since registration needs an Activity) and
   linked it from Settings.

Also: StepCounter kept one listener instead of registering a fresh one per
ring and never unregistering, and the ring screen drops its NFC handler on
dispose.

New tests cover what had been dead code: a registered tag satisfies the proof
and reveals the challenge, a foreign tag does not, and a correct answer
invokes BOTH dismiss and closeRingScreen. The emulator cannot reach the
dismiss path at all (no step sensor, no NFC), which is why it is covered by
widget tests rather than claimed from the AVD run.

Documented ringNow/main_ring_probe.dart in AGENTS.md as a deliberate manual
test handle, the phone-side equivalent of wake-alarm.service.

flutter test: 69 pass. dart analyze lib/ test/: clean. pre-commit: passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:39:01 +02:00
cb50e58a56 Phase 4/3: NFC room proof, runtime permissions, non-blocking step reads
Three defects found by reviewing the emulator run, all on the dismiss path:

1. ACTIVITY_RECOGNITION was declared but never requested at runtime (API 29+).
   Denied, TYPE_STEP_COUNTER delivers nothing, so checkSteps() reports "sensor
   unavailable" forever. With steps the only wired proof, that made the alarm
   *undismissable*, not merely degraded. MainActivity now requests it
   alongside POST_NOTIFICATIONS.

2. checkNfc/checkQr were dead code — no reader, no channel method, no
   registration — so steps was the only reachable proof. That inverts the
   decision ("NFC or QR qualifies; steps alone does NOT") and removes exactly
   the redundancy whose purpose is that one failing mechanism at 06:00 must
   never block dismissal. Added NfcReader (hand-written on the existing
   channel, NfcAdapter.enableReaderMode so the tap does not restart the
   activity and reset the challenge) plus RoomProofSettings for the registered
   tag/QR. mobile_scanner stays the droppable extra.

3. StepCounter blocked the platform main thread up to 3s per read, called from
   the MethodChannel handler — jank-to-ANR on the ring screen. Now registers a
   listener and serves the last sample; reads never block.

Also: raiseAlarmVolume() is guarded. setStreamVolume can throw under a zen
rule, and by then startForeground() has already posted — a failed volume bump
must not take the ring down with it.

Verified on the API 36 AVD with the real app widget and route (the earlier
probe rendered its own UI, so it proved the Kotlin path but nothing about
/ring): screen Asleep -> Awake, AlarmActivity top resumed over the lockscreen,
RingScreen showing the room-proof gate. New widget tests cover the /ring route
resolving and the challenge staying gated behind the room proof.

flutter test: 57 pass. dart analyze: clean. Zero Python files touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:28:24 +02:00
af2a9b416b Phase 4/3: the phone's Dart half — ring screen, challenges, room proofs
Dart side of the alarm (items 1, 4, 3). main.dart had no init seam at all;
it now has ensureInitialized(), a navigatorKey and a /ring route.

- challenge.dart: all five kinds ported from _challenges.py so both halves
  behave identically. kDismissCodeLength/kDismissFlashSeconds are duplicated
  from _constants.py, not synced — noted in the file, since Dart cannot read
  Python.
- alarm_scheduler.dart: the Dart side of the single MethodChannel.
- ring_screen.dart: room proof first, then a challenge, then dismiss. Back is
  a no-op (PopScope) so the alarm cannot be escaped.
- room_proof.dart: NFC or QR satisfies on its own; steps NEVER do alone —
  the phone is stationary while you walk to it, so steps only accrue after
  pickup and can be faked by shaking it in bed. Steps count only together
  with elapsed time, as the fallback when both others fail.
- home_screen: arms the app's own alarm instead of asking the user to confirm
  in the Clock app (item 1's "no user interaction"). SET_ALARM stays as
  redundancy with SKIP_UI=true.

Re-arm has three hooks and needs all three: startup, BOOT_COMPLETED, and
dismiss. The plan assumed LogStore.changes would cover this; that stream does
not exist in crdt_sync_dart (verified — the library has no Stream at all), so
the picker's save is only one hook and a restart would otherwise arm nothing.

Fixed a real gap the API 36 AVD exposed: POST_NOTIFICATIONS was never
requested at runtime, so a fresh install sat at importance=NONE and the
full-screen intent was rejected outright — the alarm would ring with no
dismiss screen. MainActivity now requests it, and canUseFullScreenIntent()
also checks areNotificationsEnabled() so the check gates rather than warns.

Verified on the API 36 AVD: screen Asleep -> Awake, AlarmActivity became the
top resumed activity over the lockscreen, service isForeground=true
types=0x2 (mediaPlayback) category=alarm, no crash. flutter test: 54 pass.
dart analyze: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:22:07 +02:00