Commit Graph

28 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
728e00cde7 Wake enforcement v2: PC fallback ring + RTC self-wake, stacking Tue-Thu phone lockdown, escape hatch until 2026-10-15, stakes in the app, daily incident audit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00
7f7fd916f7 Earned ramp: a dismissed Mon/Fri/Sat/Sun morning earns the minute; workdays hold, misses and free days earn nothing; workdays from freedays 0.3.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:13:22 +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
c3c129b068 Make the morning session the carrot: earn 11:00 by finishing it in time
On 2026-09-19 the phone was powered off after the bathroom tag and nothing
on the PC noticed: every finished session was published and nothing read
it. Now wake-alarm distils the phone's morning into an HMAC-signed
~/.local/state/wake_alarm/morning_session.json (wake-alarm-session.timer,
07:00-11:00 every 2 min) that screen-locker and leetcode-guard read to
defer their locks -- provisionally until min(start + 90 min, 09:30) while
the session is live, until 11:00 once it completed inside that cutoff,
and not at all for a session finished later (the phone switched on at
noon completes the legs then; that must un-lock nothing).

The phone publishes the session at start as well as at finish
(current_session, cleared with the finish stamp), so the PC can tell
"started, not finished" from "never started". A required 1-5 energy tap
joins the weigh-in and ships as latest_energy with a PC reader mirroring
_weight (the shared half is _field_reader).

Also fixes the vmbox harness, red since the 2026-09-11 home-tidy: the
guest tree lives at ~/wake-alarm, and the INSTALL-OK grep matched vm's
echo of the command line. Takes Flutter 3.47.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
2026-09-19 16:40:51 +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
07f2408f7e Re-point paths for the ~ reorganisation (home-tidy)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 18:46:34 +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
b5701fb359 Sync the NFC tags and home location so a reinstall gets them back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:34:50 +02:00
2904d97b2c Bring the phone app's analyzer up to latest stable
very_good_analysis ^10.3.0 -> ^11.0.0, which the freshness gate flagged
against phone_app/pubspec.yaml the moment the pre-push CI mirror ran
--all-files. Pre-existing; it blocked the push rather than being caused
by it.

The major bump newly flags two async_return_with_no_await sites, both now
`return await` rather than suppressed. The rule is correct in both: a
future returned without awaiting escapes the enclosing async function, so
a failure surfaces at the caller with a stack trace that no longer names
the code that made the call. firebase_backend.dart:91 is the one that
matters -- a rejected credential fetch should fail inside the function
that describes itself as recovering from exactly that.

flutter analyze clean, 146 phone-app tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:30:00 +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
60f79774c9 Fix the race that stopped the phone publishing at all
The PC has been firing the morning routine at 07:00 instead of 08:00, off a
record the phone published on 2026-08-09 and never superseded. The phone was
connected to Firebase the whole time and the home screen still said "Connect a
sync backend in Settings first."

initState fired three futures concurrently and the last one read what the
first two were still writing:

    unawaited(_applyPolicy());     // sets _hour/_minute
    unawaited(_loadSyncState());   // sets _service
    unawaited(_syncToPc());        // reads BOTH

`publish` takes the service as a *parameter*, so it captured `_service` by
value while it was still null. Assigning it moments later could never reach
the call. The startup publish therefore passed null every time and bailed out
before touching the network -- and `_syncToPc` had exactly one call site, that
one, so nothing published, ever. `_applyPolicy` has the same shape: a publish
that did win the race would have sent the baseline instead of an active
override. The bootstrap is now strictly sequential.

Three things conspired to hide it for three weeks:

- `publish` returned "Connect a sync backend in Settings first." for BOTH a
  missing backend and a null service, so a broken store looked like a setup
  step the user had never done. The advice pointed at something that was not
  broken. The two causes now report differently, with a regression test.
- `_loadSyncState` swallowed its exception with `on Exception catch (_)`, so
  if the store genuinely failed to open there was no trace anywhere. It logs
  now.
- `_pickEarlier` never published at all, so "Wake me earlier tomorrow" -- the
  one schedule edit the app offers -- never reached the PC either. It does now.

home_screen.dart went over the 250-line cap, so its body moves to
`screens/home_body.dart` as a stateless widget taking plain values. The screen
keeps the state and the ordering rules, which is where the bugs lived.

Verified on the Pixel 6a: home screen now reads "Synced to PC ✓", and the PC's
`_sync --show --no-cache` returns `08:00 (remote)` stamped seconds earlier,
against `07:00` from 2026-08-09 before. The phone had also minted a new device
id, so both records exist in Firebase and the HLC ranking picks the newer --
which is exactly why that reader ranks by HLC and not by listing order.

104 Dart tests pass; analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 16:28:20 +02:00
150a8cd7e5 Require two tags, the walk, and three answers to dismiss
The dismiss gate was an OR — NFC tag *or* QR *or* (25 steps + 2 min) —
followed by one challenge. QR never worked at ring time (no scanner
package, no CAMERA permission, `onQrScanned` had no caller), so the real
bar was "walk 25 steps, wait 2 minutes, answer one question" and the NFC
tag was already fully bypassable.

It is now a strict, latched sequence owned by `DismissGate`:

    tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges

Every stage is required, in that order, and a stage once passed never
reverts. That last part also fixes a real bug: `_onNfcTag` and `_trySteps`
both assigned the room-proof result straight onto `_inRoom`, so a wrong
tag tapped after the gate had passed sent the user back to the start.

Two *distinct* tags are required; the same id in both slots is refused at
registration, since one tag satisfying both stages removes the walk that
is the whole point. The existing single-tag registration migrates into
slot 1 rather than being stranded.

Answers are cumulative, not consecutive: a wrong one costs the attempt and
nothing else.

The bypass is the only fallback and is deliberately narrow. After 5 minutes
of ringing, or immediately when NFC is off or fewer than two distinct tags
are registered, the two *tag* stages are skipped and the count rises to 6.
The walk is never skipped — jumping straight to the challenges would let
someone lie in bed for five minutes and answer questions without standing
up, which is weaker than the gate this replaces. Without some such hatch a
dead tag at 06:00 leaves an alarm that cannot be switched off at all.

Also deletes the `flash` challenge outright, along with the now-orphaned
`generateCode`/`kDismissCodeLength`/`_dismissChars`. It was the only kind
that removed its prompt from the screen, which is unsolvable for someone
who has just walked in from another room.

The ring screen takes an injectable clock: `Stopwatch` reads the wall
clock, which `tester.pump()` does not advance, so without that seam no
widget test could reach the two-minute floor or the five-minute bypass.

Verified on the Pixel 6a: the existing tag migrated into slot 1 and both
rows render. 102 Dart tests pass; analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 15:09:00 +02:00
1c8a40d410 Split _sinks.py and home_screen.dart under the 250-line gate
_sinks.py 341 -> 249: the pactl *parsing* moves to _sink_parsing.py. That half
is pure text handling with no subprocess and no side effects, so it is
testable without touching the host's audio; _sinks.py keeps command execution
and the activate/restore orchestration.

The parsers are re-imported into _sinks under their original names, guarded by
__all__. The suite patches `wake_alarm._sinks._parse_cards` and friends, and a
moved symbol makes patch() stop intercepting *silently* — the test then
asserts nothing and still passes. Ruff proved the point mid-split: shortening
__all__ let it delete two of the re-exports and broke an import outright.
_parse_profile_line is only imported (never patched), so its test now imports
from the new module instead.

home_screen.dart 270 -> 249. This one was mine — the automatic-alarm change
pushed it over and I claimed in TODO-push-and-gate.md that nothing this
session touched was over the limit; that was wrong. Publishing moves to
AlarmPublisher, which returns a status line instead of calling setState, so it
is testable without a widget. _loadSavedTime became _loadSyncState: it no
longer loads a *time*, because the schedule is baked in and owned by
AlarmPolicy, so a stored value could only disagree with it.

465 Python at 100% coverage, 79 Dart, APK builds, and both documented CLIs
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:30:02 +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
a845cac883 Remove the flash challenge; revert the MethodChannel split that regressed NFC
Two things, one of them an undo of my own change.

The flash challenge is gone from the phone's rotation. It hides the code after
kDismissFlashSeconds, and on the phone that is unsolvable in practice: the ring
screen is reached by walking to another room and tapping a tag, so the code has
already vanished by the time the user is looking at it. Reported from the phone
as "invisible - impossible to actually do". Every challenge the alarm shows now
stays on screen until it is answered, and a test asserts next() never returns
one that hides itself. The constructor stays for the PC half's parity tests;
nothing routes to it.

The two-channel split is reverted. I introduced it to fix a tag reaching Kotlin
but not Dart, and pointed the push channel at the call channel — which broke
NFC *and* the step counter, both of which had been confirmed working on the
phone one build earlier. The user caught it: "they worked previously so your
changes regressed it". The split was a theory that was never shown to be
necessary; the single channel from 08b44b4 is restored unchanged.

Lesson worth keeping: when hardware has just confirmed a path works, the next
change should be the smallest possible delta on top of that exact state, not a
restructure bundled with a fix.

flutter test: 70 pass. dart analyze lib/ test/: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 22:45:35 +02:00
10a594305c Bring the two files this change grew back under the 250-line gate
The gate runs --all-files at push time, so it caught two files this session
pushed over: AGENTS.md (244 -> 330) and home_screen.dart (238 -> 269). Both
are mine; the pre-existing Python overages are untouched, per the standing
rule not to split a file this change does not already have to edit.

- The phone section moves to DOCS-phone-alarm.md, following the repo's own
  pattern (DOCS-architecture.md, DOCS-vmbox.md), leaving a pointer.
- Alarm arming moves out of home_screen.dart into AlarmArming — "what it
  takes to arm an alarm" was never the picker's concern, and the extraction
  also repaired a doc comment my earlier edit had orphaned onto the wrong
  method.

flutter test: 69 pass. dart analyze lib/ test/: clean. APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:43:16 +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
b9abbf7043 phone_app: use the shared github_device_auth package
The inline `_DeviceCodeDialog` (86 lines) was byte-for-byte identical to
diet-guard's, and `services/github_device_auth.dart` was a fourth copy of
the auth client. Both now come from `github_device_auth` in the
kuhyx/utils monorepo.

This app's client was not merely a drifted copy -- it carried a fix the
other three lacked: `pollForToken` swallowed a transient socket failure and
kept polling, because GitHub can close the connection at the exact moment
the user authorizes. Extracting todo's version would have silently deleted
that. The package took this behaviour instead, so the fix now protects all
four apps rather than just this one.

It is expressed as `on http.ClientException` rather than the local
`SocketException` arm, because the package is also imported by
diet-guard's **web** build where `dart:io` will not compile. Verified that
nothing is lost: package:http wraps a dead socket as
`_ClientSocketException`, which implements both types.

19 tests pass, analyze clean, and the release APK is installed on the
phone and launching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 16:16:17 +02:00
37a7829757 phone_app: add Google sign-in support for the Firebase sync account
Same pattern as todo, diet_guard_app, and workout_app: openFirebaseWithGoogle()
(google_platform.dart abstracts the plugin's platform-channel dependence),
isFirebaseConfigured() reading the stored session rather than the account
marker alone, and the settings screen offering "Sign in with Google" ahead
of the password form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 11:26:33 +02:00
03392e7ea5 Discover alarm device directories instead of reading a fixed phone path
The PC read one hardcoded path, wake-alarm-sync/devices/phone/alarm.json,
and never parsed the HLC. The phone's device id is migrating from the fixed
constant "phone" to a per-install uuid (a role constant collides as soon as
a second install takes the same role, and a reinstall inherits the previous
install's CRDT identity). A reader pinned to the old path would have stopped
seeing the alarm the moment the phone migrated, fallen through to the cache
and then to FALLBACK_ALARM_HOUR -- i.e. woken at the wrong time, silently.

So the PC half ships first, and this commit is that half plus the phone
change it makes safe:

- _fetch_firebase_alarm() lists SYNC_DEVICES_PREFIX, reads every device's
  alarm.json, and picks the newest by the hour field's HLC rather than by
  listing order. device_id breaks an exact tie so two devices stamped in the
  same millisecond don't alternate between ticks; a device whose payload
  doesn't parse is skipped rather than allowed to win with a bad value.
- SYNC_ALARM_PATH stays as a last-resort read for a backend that lists
  nothing, so an un-migrated install keeps working. The GitHub mirror still
  reads the single fixed path.
- The phone persists a uuid under SharedPreferences 'crdt.nodeId' (the key
  todo and home_inventory already use) and pushes under it. deviceId is now
  an instance field, injectable so tests don't touch real prefs.

Parsing and ranking moved to _alarm_payload.py: _sync.py was over the repo's
500-line cap, and the wire format deserves testing without network or disk --
a misparse here doesn't raise, it just resolves to the wrong wake-up time.

Verified against the live RTDB, not just mocks: seeded a uuid-named device
directory and confirmed the reader discovered it, read it and ranked it --
losing to the phone with an old stamp, winning with a new one (03:15 vs the
real 07:45). Test device deleted; --show returns 07:45 and the cache is
intact.

354 tests, 100% branch coverage. Phone app: 10 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 17:06:43 +02:00
98f6c5c26c Sync the phone's alarm through Firebase, mirroring to GitHub
Completes the cutover started on the PC side: phone_app was still pinned to
crdt_sync_dart-v0.2.0, so the Flutter half was writing only to GitHub even
though _sync.py had already moved.

pushWith() now takes a RemoteStore instead of a GitHubClient, so one call
serves Firebase, GitHub, or the MirrorStore that writes both; syncBackend()
picks which, and returns the bare GitHub client when this device has not been
set up for Firebase -- the normal cutover state, and also the rollback. The
remote path is byte-identical on both backends, so only the transport differs
and the PC's parse is untouched.

Settings gained the Firebase section that actually calls saveAccount(). Its
absence was the bug found in diet-guard: without it openFirebase() always
returned null, so the app was a Firebase no-op on real hardware no matter
what the tests said. Signing in happens immediately on Connect, so a wrong
password surfaces on-screen rather than as a silent failure at push time.

The three UI problems kuhy hit in diet-guard are avoided here rather than
repeated: GitHub is demoted into "Advanced (GitHub mirror)" instead of
sitting beside Firebase as a peer choice, the connected account renders as
read-only text with a Disconnect button rather than an empty-looking form,
and the status card reports the Firebase state it is actually reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
2026-08-05 09:20:02 +02:00
512fdd5c95 Migrate phone_app alarm sync from GitHub Gist to crdt_sync
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
phone_app: the alarm now syncs through the shared crdt_sync transport
(v0.2.0) into the fixed private repo kuhyx/syncs, replacing the bespoke
Gist. AlarmSyncService is the sole writer: it holds the alarm locally in a
crdt_sync LogStore (one HLC-stamped Record) and pushes it to
wake-alarm-sync/devices/phone/alarm.json. gist_service.dart is deleted;
SyncSettings drops the Gist id and keeps only a token; the device-flow scope
moves from `gist` to `repo` (the Contents API needs private-repo access).

shutdown-wrapper.sh: the PC reader now fetches that alarm from the Contents
API (Accept: application/vnd.github.raw, so no base64) and parses the
crdt_sync Record Log with jq (.alarm.fields.alarm_hour[0]). Single-file read
of the sole "phone" device; unchanged fallback to WAKE_AFTER_HOURS on any
miss so a parse failure can never brick the wake. Config moves from
gist_sync.json {token, gist_id} to sync.json {token}.

Verified: flutter analyze clean + tests pass; shellcheck clean and the full
get_synced_alarm_epoch verified against the real Log format; on-device the
app builds, installs with adb install -r (no data loss), launches and
renders. Live round-trip needs a one-time repo-scoped re-auth on the phone
and ~/.config/wake_alarm/sync.json on the PC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 22:45:14 +02:00
0704e44912 Add wake_alarm_sync phone-side Flutter companion app
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
New Flutter app (com.kuhy.wake_alarm_sync) that sets the phone alarm and
writes the wake time to a GitHub Gist for cross-device sync: Gist service,
GitHub device-flow auth, sync settings, home/settings screens. Source only;
build/.dart_tool/.idea artifacts excluded by the app's .gitignore.

Not yet gated by CI (wake-alarm CI is Python-only) and pending on-device
verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
2026-07-10 23:16:11 +02:00