wake-alarm/DOCS-morning-session.md
Krzysztof kuhy Rudnicki 69be24d4c0 Session: tag catch-up + pre-deadline buzz
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:25:15 +01:00

11 KiB
Raw Permalink Blame History

The morning session — what happens after the ring stops

The ring proved you stood up. It did not stop you lying back down: the 2026-09-16 observation was "I do the tags, the walk and the questions, and then go back to sleep". So since that day the dismiss does not end the morning. It hands over, inside the same AlarmActivity, to a silent checklist that only ends when you are showered, dressed and sitting at the desk — and the alarm comes back if a step is missed or the phone lies still.

Owner: phone_app/lib/services/morning_session.dart (the record), session_rules.dart (what moves it), screens/session_screen.dart (the driver), session_handoff.dart (how the ring reaches it).

The legs

weigh in  ->  bathroom tag (≤ 2 min later)  ->  shower  ->  bathroom tag again
          ->  wardrobe tag  ->  desk tag  ->  done
leg passes when window (re-ring at) notes
bathroomIn bathroom tag with a pending weight ≤ 2 min old start + 10 min the typed weight is not a leg; the tag is what proves you walked to the scale
showerDone bathroom tag ≥ 8 min after bathroomIn bathroomIn + 18 min its own window: 10 min composed with the 8-min floor would be a two-minute target, hit wet
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 else. Away from home (RingSkip.away) there is no session.

Weight and energy. The weight is typed, 30–250 kg; more than 3 kg from the last confirmed value must be typed twice (weight_entry.dart). The energy is one of five chips, 1–5, and is required before the weight submits (session_checkin.dart): not proof of anything, just the one number worth a tap while standing at the scale. Without a bathroom tag the check-in passes the first leg by itself — there is nothing to walk to.

What rings, and how

  • A missed window re-rings through an AlarmManager alarm (AlarmScheduler.scheduleReRing, request code 1003, setAlarmClock like the daily one). Not a Dart timer: the session screen can be killed, paused or rebooted away, and the deadline has to survive all three. BootReceiver restores it; restoreReRing drops an instant that has already passed (see Gotchas).
  • No still-phone check. An accelerometer watch (MotionMonitor.kt, 4 min without motion) existed for one day and was deleted on 2026-09-17: it only ran while the session screen was in front, and the real routine leaves the app on purpose (a podcast before the shower), so it was off for most of the session and never rang on the one morning it was watched. The deadlines are the only re-ring. Do not bring it back as a foreground service without a morning that shows a lie-in the ≤ 10-minute windows missed.
  • When Dart rings a deadline itself it routes to /ring afterwards, in case the full-screen intent lands on the live singleInstance activity instead of recreating it.
  • 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.
  • Three re-rings per morning (maxReRings). A window missed with none left ends the session as failed, silently. This is the agreed cap and the known weakness: three rings of misery buy silence.
  • The Dart tick also rings a deadline the native alarm should have fired (20 s grace) — ringing twice is harmless, missing it is not.

Everything is persisted after every change (session_store.dart, session.current) and driven by wall-clock instants, because a re-ring replaces the whole Flutter engine and resumes from the store.

Leaving the app, and coming back

The checklist lives in AlarmActivity (NFC reader mode and the kept-on screen are asserted there), and the launcher starts MainActivity. Until 2026-09-17 a home gesture or a recents swipe therefore lost the screen for good: the re-ring still fired, but the app opened on the home screen with no way back to the leg. Now every launch while the morning is live lands in AlarmActivity, and the home screen is unreachable until the session is over:

  • MainActivity.resumeMorning() runs in onCreate and onResume (the activity is singleTop; a warm instance never re-runs onCreate), before any Dart and before the permission prompt — a permission dialog must never come up over a ring. It starts AlarmActivity with the route extra and finishes itself.
  • It decides natively, from SessionNotice.kt: a cache Dart rewrites on every leg change (updateSessionNotice, always paired with armReRing in session_notice.dart) holding the session's date and the leg label. A cache dated any other day is ignored, never resumed — the phone dying mid-morning must not open a dead checklist the next evening. Flutter's own preferences file is deliberately not parsed from Kotlin.
  • A sounding ring wins. AlarmService.running routes the launch — and AlarmActivity.getInitialRoute() — to /ring whatever the extra says, so a swiped-away ring reopened from the launcher gets the dismiss sequence, not the home screen or a checklist over the noise.
  • An ongoing notification ("Morning session — Tap the bathroom tag", counting down to the re-ring) is one tap back to the leg. Silent channel, setOnlyAlertOnce, native chronometer, so Dart posts it per leg, not per tick. finishSession is the only thing that cancels it. The launcher redirect is the guarantee; the notification is the convenience.
  • main_session_probe.dart asks morningLive before its test ring, so a relaunch of the probe during a session resumes rather than rings.

History and sync

Finished sessions go to session.history (30 kept). The home screen shows the last seven (home_history_card.dart) with weight deltas. The record rides on the synced alarm.json like the ring tags do (session_sync.dart): session_tag_ids, morning_sessions (14), latest_weight_kg ({date, kg}), latest_energy ({date, level}) and current_session — the live session while one runs, {} once it is over. The PC reads the two numbers with python -m wake_alarm._weight / _energy (Firebase only, same device discovery as the alarm); diet-guard's sync imports the weight lazily for its protein target. The PC alarm parser ignores every field it does not name. Sessions merge per date, newest updatedAt wins, so a reinstall gets its history back; current_session is never applied from a peer — a live session in history would stop SessionHandoff.afterDismiss starting the morning it belongs to.

The session is published twice: once when it starts (SessionScreen._init, strictly after the session is saved and on screen — a publish racing the load stages nothing, and the PC would then lock an honest morning) and once when it finishes. That is what lets the PC tell "started, not finished" from "never started" and defer the workout and LeetCode locks only while the morning is actually being earned: DOCS-morning-session-pc.md.

Gotchas — each one cost a real run

  • Route replacement runs the new screen's initState before the old screen's dispose. The ring screen's dispose used to clear the NFC handler the session had just registered, so the session never heard a tag (Pixel, 2026-09-16). AlarmScheduler.clearNfcListener now clears only if that instance registered the handler. The emulator harness gives each screen its own instance for the same reason.
  • Android replays BOOT_COMPLETED to a force-stopped app on its next launch. After am force-stop during a test, a plain launch delivered BOOT_COMPLETED (WakeAlarmBoot log), restoreReRing re-armed a nine-minute-old deadline, and the phone rang in the evening with no session running. A stored instant in the past is now dropped, never fired.
  • ValueKey<int>(syncEpoch) twice in one Column — the history card and the presence line — mis-reconciled silently in release and painted the old presence element in the card's slot. Keys in one child list must differ.
  • The steps stage's 2-minute floor now applies only under bypass (no tag proved the walk). On the normal path tag 1 has already banked the steps, so the floor was two minutes of ringing nothing could shorten.
  • developer.log is invisible in release logcat. Session and challenge logs use debugPrint (WakeAlarmSession:, WakeAlarmRing:), which is what the emulator harness and a bad morning are debugged from.
  • The volume ramp is ten minutes to full, not one.

Testing it without a bathroom

lib/main_session_probe.dart runs the real app on FileDrivenScheduler (lib/probe/), which replaces NFC and the step count with files under /sdcard/Android/data/com.kuhy.wake_alarm_sync/files/. phone_app/scripts/emu_morning.py drives it:

flutter run -d emulator-5554 --debug -t lib/main_session_probe.dart > run.log
scripts/emu_morning.py --log run.log dismiss    # ring1, 30 steps, ring2, answers
scripts/emu_morning.py energy 3                 # one of the five chips
scripts/emu_morning.py weight 72.4
scripts/emu_morning.py tag bath                 # then wait 8 min, tag bath again
scripts/emu_morning.py home; scripts/emu_morning.py launch   # back on the leg
scripts/emu_morning.py rering                   # the deadline broadcast, now
scripts/emu_morning.py --log run.log dismiss    # resumes at the same leg, 1/3
scripts/emu_morning.py tag ward; scripts/emu_morning.py tag desk

Verified this way on 2026-09-16 (API 36 AVD): the whole morning including a re-ring and the resume; on 2026-09-17 the home-and-relaunch round trip on a leg and during a ring. flutter build apk --debug --target produced an APK that ran main.dart instead; flutter run -t did not. The phone remains the only test of the AlarmManager deadline with the screen off and the app swiped away.