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>
This commit is contained in:
Krzysztof Rudnicki 2026-09-17 09:01:22 +02:00
parent c7e7695466
commit af293413c0
24 changed files with 632 additions and 199 deletions

View File

@ -52,10 +52,10 @@ passing sent the user back to the start.
**The dismiss does not end the morning.** Since 2026-09-16 it hands over, in **The dismiss does not end the morning.** Since 2026-09-16 it hands over, in
the same activity, to the silent **morning session**: weigh in → bathroom tag the same activity, to the silent **morning session**: weigh in → bathroom tag
→ shower → bathroom tag again → wardrobe tag → desk tag. A missed window → shower → bathroom tag again → wardrobe tag → desk tag. A missed window
(AlarmManager, request code 1003) or a phone still for 4 min re-rings, and (AlarmManager, request code 1003) re-rings; silencing it is the full sequence
silencing that is the full sequence above again; three re-rings fail the again; three re-rings fail the session. Leaving the app is fine: every launch
session. State is persisted after every change and driven by wall-clock while the morning is live lands back on the leg (`SessionNotice.kt`). State is
instants, never Dart timers: **`DOCS-morning-session.md`**. persisted after every change, on wall-clock instants: **`DOCS-morning-session.md`**.
**`HomeScreen.initState` must stay strictly sequential** (`_applyPolicy` → **`HomeScreen.initState` must stay strictly sequential** (`_applyPolicy` →
`_loadSyncState` → `_syncToPc`): run concurrently, the startup publish `_loadSyncState` → `_syncToPc`): run concurrently, the startup publish

View File

@ -42,12 +42,16 @@ value passes the first leg by itself — there is nothing to walk to.
or rebooted away, and the deadline has to survive all three. `BootReceiver` or rebooted away, and the deadline has to survive all three. `BootReceiver`
restores it; `restoreReRing` drops an instant that has already passed restores it; `restoreReRing` drops an instant that has already passed
(see *Gotchas*). (see *Gotchas*).
- **A still phone** (accelerometer, `MotionMonitor.kt`, no motion for 4 min) - **No still-phone check.** An accelerometer watch (`MotionMonitor.kt`, 4 min
re-rings from Dart via `ringNow`, then routes itself to `/ring` in case the without motion) existed for one day and was deleted on 2026-09-17: it only
full-screen intent lands on the live `singleInstance` activity instead of ran while the session screen was in front, and the real routine leaves the
recreating it. Suspended during the shower leg — the phone is on a shelf. app on purpose (a podcast before the shower), so it was off for most of the
Only works while the session screen is up, which is why `AlarmActivity` session and never rang on the one morning it was watched. The deadlines are
now holds `FLAG_KEEP_SCREEN_ON`; the deadline alarm is the backstop. 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, - **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 tag 2, three questions. The ring screen's `_finishDismiss` sees a live
session for today and resumes it with `reRings + 1`. session for today and resumes it with `reRings + 1`.
@ -64,6 +68,39 @@ Everything is **persisted after every change** (`session_store.dart`,
`session.current`) and driven by wall-clock instants, because a re-ring `session.current`) and driven by wall-clock instants, because a re-ring
replaces the whole Flutter engine and resumes from the store. 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 ## History and sync
Finished sessions go to `session.history` (30 kept). The home screen shows Finished sessions go to `session.history` (30 kept). The home screen shows
@ -104,8 +141,8 @@ reinstall gets its history back.
## Testing it without a bathroom ## Testing it without a bathroom
`lib/main_session_probe.dart` runs the real app on `FileDrivenScheduler` `lib/main_session_probe.dart` runs the real app on `FileDrivenScheduler`
(`lib/probe/`), which replaces NFC, the step count and the motion watch with (`lib/probe/`), which replaces NFC and the step count with files under
files under `/sdcard/Android/data/com.kuhy.wake_alarm_sync/files/`. `/sdcard/Android/data/com.kuhy.wake_alarm_sync/files/`.
`phone_app/scripts/emu_morning.py` drives it: `phone_app/scripts/emu_morning.py` drives it:
```sh ```sh
@ -113,13 +150,15 @@ 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 --log run.log dismiss # ring1, 30 steps, ring2, answers
scripts/emu_morning.py weight 72.4 scripts/emu_morning.py weight 72.4
scripts/emu_morning.py tag bath # then wait 8 min, tag bath again scripts/emu_morning.py tag bath # then wait 8 min, tag bath again
scripts/emu_morning.py idle 250000 # still phone -> re-ring 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 --log run.log dismiss # resumes at the same leg, 1/3
scripts/emu_morning.py tag ward; scripts/emu_morning.py tag desk 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 Verified this way on 2026-09-16 (API 36 AVD): the whole morning including a
still-phone re-ring and the resume. `flutter build apk --debug --target` 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. 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 The phone remains the only test of the AlarmManager deadline with the
screen off and the app swiped away. screen off and the app swiped away.

View File

@ -20,7 +20,19 @@ class AlarmActivity : FlutterActivity() {
/** Dart route the ring screen is registered under in main.dart. */ /** Dart route the ring screen is registered under in main.dart. */
private val ringRoute = "/ring" private val ringRoute = "/ring"
override fun getInitialRoute(): String = ringRoute /**
* The ring by default; the session checklist only when asked for by
* [SessionNotice.resumeIntent] (the launcher redirect or the ongoing
* notification) AND nothing is sounding. A ring always wins: an intent
* that opened the checklist over a live alarm would leave the sound
* running with no dismiss sequence in front of it. Read once, at
* creation -- a live `singleInstance` instance just comes forward on
* whatever screen Dart is showing, which is the right thing.
*/
override fun getInitialRoute(): String {
if (AlarmService.running) return ringRoute
return intent?.getStringExtra(SessionNotice.EXTRA_ROUTE) ?: ringRoute
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// showWhenLocked/turnScreenOn are also set as manifest attributes; the // showWhenLocked/turnScreenOn are also set as manifest attributes; the
@ -31,8 +43,8 @@ class AlarmActivity : FlutterActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// The morning session lives in this activity for half an hour after // The morning session lives in this activity for half an hour after
// the ring stops. With the screen allowed to sleep the activity // the ring stops. With the screen allowed to sleep the activity
// pauses, NFC stops polling and the motion watch stops sampling, so // pauses and NFC stops polling, so a tag tap would need a wake and
// the only thing left working would be the AlarmManager backstop. // an unlock first, every leg.
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
dismissKeyguard() dismissKeyguard()
} }

View File

@ -108,14 +108,31 @@ object AlarmChannel {
// no off switch. // no off switch.
"finishSession" -> { "finishSession" -> {
AlarmScheduler.clearReRing(context) AlarmScheduler.clearReRing(context)
MotionMonitor.stop(context) SessionNotice.clear(context)
result.success(null) result.success(null)
} }
"beginMotionWatch" -> { // The live leg, for the launcher redirect and the ongoing
MotionMonitor.start(context) // notification; see SessionNotice.kt. Called on every leg
result.success(null) // change, never per tick.
"updateSessionNotice" -> {
val date = call.argument<String>("date")
val label = call.argument<String>("label")
if (date == null || label == null) {
result.error("bad_args", "date and label are required", null)
} else {
val at = call.argument<Number>("deadlineMillis")?.toLong()
SessionNotice.update(context, date, label, at)
result.success(null)
}
} }
"motionIdleMillis" -> result.success(MotionMonitor.idleMillis()) // Whether a launch right now would be redirected into the
// morning; the probes ask before starting a test ring.
"morningLive" ->
result.success(AlarmService.running || SessionNotice.isLiveToday(context))
// Whether the ring is sounding; the session screen polls this so
// a deadline that rang while it sat in the background puts the
// dismiss sequence in front the moment it is looked at.
"isRinging" -> result.success(AlarmService.running)
"beginStepCount" -> { "beginStepCount" -> {
StepCounter.markRingStart(context) StepCounter.markRingStart(context)
result.success(null) result.success(null)

View File

@ -46,6 +46,15 @@ class AlarmService : Service() {
/** Vibration: 1s buzz, 1s gap, repeating from index 0. */ /** Vibration: 1s buzz, 1s gap, repeating from index 0. */
private val VIBRATION_PATTERN = longArrayOf(0, 1000, 1000) private val VIBRATION_PATTERN = longArrayOf(0, 1000, 1000)
/**
* Whether the ring is sounding right now. Same process as both
* activities, so a launch during a ring can route to the dismiss
* screen instead of the home screen or a session checklist.
*/
@Volatile
var running: Boolean = false
private set
/** Starts the ring. Safe to call when already ringing — start is idempotent. */ /** Starts the ring. Safe to call when already ringing — start is idempotent. */
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, AlarmService::class.java) val intent = Intent(context, AlarmService::class.java)
@ -54,6 +63,11 @@ class AlarmService : Service() {
/** Stops the ring and releases every resource it holds. */ /** Stops the ring and releases every resource it holds. */
fun stop(context: Context) { fun stop(context: Context) {
// Cleared here, not only in onDestroy: stopService is
// asynchronous, and the session screen that follows a dismiss
// polls this flag from its first tick -- a stale true would
// re-ring the alarm it had just silenced.
running = false
context.stopService(Intent(context, AlarmService::class.java)) context.stopService(Intent(context, AlarmService::class.java))
} }
} }
@ -72,12 +86,14 @@ class AlarmService : Service() {
acquireWakeLock() acquireWakeLock()
startSound() startSound()
startVibration() startVibration()
running = true
// START_STICKY: if the system kills us under memory pressure while the // START_STICKY: if the system kills us under memory pressure while the
// user is still asleep, the alarm comes back rather than going silent. // user is still asleep, the alarm comes back rather than going silent.
return START_STICKY return START_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
running = false
stopSound() stopSound()
stopVibration() stopVibration()
releaseWakeLock() releaseWakeLock()

View File

@ -20,9 +20,28 @@ class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (resumeMorning()) return
requestAlarmPermissions() requestAlarmPermissions()
} }
/**
* Steps aside into [AlarmActivity] while the morning is live: a ring is
* sounding, or today's session is still on a leg.
*
* Decided here, natively, before any Dart runs: the home screen must
* never come up mid-session (a home gesture used to be a way out with
* no way back, 2026-09-17), and the permission prompt above must never
* appear over a ring. Checked on resume as well as create -- this
* activity is `singleTop`, and a warm instance brought back from the
* launcher never re-runs onCreate.
*/
private fun resumeMorning(): Boolean {
if (!AlarmService.running && !SessionNotice.isLiveToday(this)) return false
startActivity(SessionNotice.resumeIntent(this))
finishAndRemoveTask()
return true
}
/** /**
* Asks for the two runtime permissions the alarm cannot work without. * Asks for the two runtime permissions the alarm cannot work without.
* *
@ -114,6 +133,7 @@ class MainActivity : FlutterActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (resumeMorning()) return
if (registeringTag) alarmChannel?.let { NfcReader.start(this, it) } if (registeringTag) alarmChannel?.let { NfcReader.start(this, it) }
} }

View File

@ -1,77 +0,0 @@
package com.kuhy.wake_alarm_sync
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.SystemClock
import kotlin.math.abs
import kotlin.math.sqrt
/**
* How long the phone has lain still, for the morning session.
*
* A phone that does not move for minutes after the alarm is a phone on the
* nightstand next to someone who went back to bed. The accelerometer rather
* than the step sensors: sitting down is not going back to sleep, and a
* phone in a pocket keeps moving without a single step being taken.
*
* Hand-written on the existing channel, like [StepCounter]: nothing on the
* ring path may depend on a pub plugin compiling. Only meaningful while the
* session screen keeps the screen on -- an unregistered or suspended sensor
* reports nothing, which the Dart side reads as "unknown", never as "still".
*/
object MotionMonitor {
/** Change in acceleration magnitude (m/s²) between samples that counts as motion. */
private const val THRESHOLD = 0.6f
/** Sentinel for "not watching". */
private const val NONE = -1L
@Volatile
private var lastMotionAt: Long = NONE
private var lastMagnitude: Float = Float.NaN
private var listener: SensorEventListener? = null
/** Starts watching; the idle clock begins now, so a resume never inherits an old gap. */
fun start(context: Context) {
stop(context)
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return
lastMotionAt = SystemClock.elapsedRealtime()
lastMagnitude = Float.NaN
val l = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val (x, y, z) = event.values
val magnitude = sqrt(x * x + y * y + z * z)
if (!lastMagnitude.isNaN() && abs(magnitude - lastMagnitude) > THRESHOLD) {
lastMotionAt = SystemClock.elapsedRealtime()
}
lastMagnitude = magnitude
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
manager.registerListener(l, sensor, SensorManager.SENSOR_DELAY_NORMAL)
listener = l
}
/** Milliseconds since the phone last moved, or -1 when not watching. */
fun idleMillis(): Long {
val since = lastMotionAt
if (since == NONE || listener == null) return NONE
return SystemClock.elapsedRealtime() - since
}
/** Stops watching and forgets the clock. */
fun stop(context: Context) {
val l = listener ?: return
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
manager.unregisterListener(l)
listener = null
lastMotionAt = NONE
}
}

View File

@ -0,0 +1,108 @@
package com.kuhy.wake_alarm_sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import java.time.LocalDate
/**
* The native copy of the live morning session: which leg is waiting and when
* it re-rings, plus the ongoing notification that shows the same.
*
* 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.
* Dart rewrites this cache on every leg change (`updateSessionNotice`) and
* `finishSession` clears it, so it is only ever as stale as the last change.
*
* The record carries the session's date and [isLiveToday] refuses any other:
* a cache left behind by a session that never finished (the phone died
* mid-morning) must not open a dead checklist the next evening -- the same
* class of bug as the re-ring instant `restoreReRing` drops.
*
* Fail closed: unreadable means "no session", so a corrupt cache costs the
* shortcut, never the home screen.
*/
object SessionNotice {
private const val TAG = "WakeAlarmSession"
private const val PREFS = "wake_alarm_session"
private const val KEY_DATE = "date"
private const val KEY_LABEL = "label"
private const val CHANNEL_ID = "wake_alarm_session"
private const val NOTIFICATION_ID = 43
/** Intent extra naming the Dart route [AlarmActivity] should open with. */
const val EXTRA_ROUTE = "route"
/** The session screen's route in main.dart. */
const val SESSION_ROUTE = "/session"
/** Records [label] for [date] and posts (or updates) the notification. */
fun update(context: Context, date: String, label: String, deadlineMillis: Long?) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_DATE, date)
.putString(KEY_LABEL, label)
.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" },
)
manager.notify(NOTIFICATION_ID, build(context, label, deadlineMillis))
}
/** Drops the record and the notification; the session's finish path only. */
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.cancel(NOTIFICATION_ID)
}
/** Whether a session dated today (local calendar day) is still live. */
fun isLiveToday(context: Context): Boolean = try {
val date = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_DATE, null)
date == LocalDate.now().toString()
} catch (e: RuntimeException) {
Log.w(TAG, "session cache unreadable; treating as no session", e)
false
}
/** Opens [AlarmActivity] on the session screen, or brings the live one forward. */
fun resumeIntent(context: Context): Intent =
Intent(context, AlarmActivity::class.java)
.putExtra(EXTRA_ROUTE, SESSION_ROUTE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
private fun build(context: Context, label: String, deadlineMillis: Long?): Notification {
val tap = PendingIntent.getActivity(
context,
NOTIFICATION_ID,
resumeIntent(context),
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)
.setContentIntent(tap)
.setOngoing(true)
// Re-posted on every leg change; a buzz each time would be noise.
.setOnlyAlertOnce(true)
if (deadlineMillis != null) {
// The system renders the countdown itself, so Dart never has to
// tick this notification: one post per leg is enough.
builder.setWhen(deadlineMillis)
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setShowWhen(true)
}
return builder.build()
}
}

View File

@ -54,8 +54,11 @@ class WakeAlarmSyncApp extends StatelessWidget {
routes: <String, WidgetBuilder>{ routes: <String, WidgetBuilder>{
'/': (_) => const HomeScreen(), '/': (_) => const HomeScreen(),
kRingRoute: (_) => RingScreen(scheduler: newScheduler?.call()), kRingRoute: (_) => RingScreen(scheduler: newScheduler?.call()),
// Reached only by pushReplacement from the ring screen, inside // Reached by pushReplacement from the ring screen, or as
// AlarmActivity; it is never an initial route. // AlarmActivity's initial route when the launcher or the ongoing
// notification reopens a live session (SessionNotice.kt). Always
// inside AlarmActivity: NFC reader mode and the kept-on screen live
// there.
kSessionRoute: (_) => SessionScreen(scheduler: newScheduler?.call()), kSessionRoute: (_) => SessionScreen(scheduler: newScheduler?.call()),
}, },
); );

View File

@ -8,9 +8,11 @@ import 'package:wake_alarm_sync/services/session_tag_settings.dart';
/// Emulator entrypoint: the whole morning, driven from adb. /// Emulator entrypoint: the whole morning, driven from adb.
/// ///
/// Like `main_ring_probe.dart` it rings on launch, but it runs the app on a /// Like `main_ring_probe.dart` it rings on launch -- unless the morning is
/// [FileDrivenScheduler] and registers fixed tag ids, so the dismiss walk /// already live, in which case the launch is redirected into it exactly as
/// and the session legs can be fed by writing files: /// the launcher's would be -- but it runs the app on a [FileDrivenScheduler]
/// and registers fixed tag ids, so the dismiss walk and the session legs can
/// be fed by writing files:
/// ///
/// ```sh /// ```sh
/// F=/sdcard/Android/data/com.kuhy.wake_alarm_sync/files /// F=/sdcard/Android/data/com.kuhy.wake_alarm_sync/files
@ -27,6 +29,7 @@ Future<void> main() async {
await SessionTagSettings.saveTagAt(SessionTagSlot.bathroom, 'bath'); await SessionTagSettings.saveTagAt(SessionTagSlot.bathroom, 'bath');
await SessionTagSettings.saveTagAt(SessionTagSlot.wardrobe, 'ward'); await SessionTagSettings.saveTagAt(SessionTagSlot.wardrobe, 'ward');
await SessionTagSettings.saveTagAt(SessionTagSlot.desk, 'desk'); await SessionTagSettings.saveTagAt(SessionTagSlot.desk, 'desk');
await AlarmScheduler().ringNow(); final scheduler = AlarmScheduler();
if (!await scheduler.morningLive()) await scheduler.ringNow();
runApp(const WakeAlarmSyncApp(newScheduler: FileDrivenScheduler.new)); runApp(const WakeAlarmSyncApp(newScheduler: FileDrivenScheduler.new));
} }

View File

@ -11,11 +11,10 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
/// The emulator has no NFC and no step sensor, so the real dismiss sequence /// The emulator has no NFC and no step sensor, so the real dismiss sequence
/// and the whole morning session are unreachable on it. This bridge keeps /// and the whole morning session are unreachable on it. This bridge keeps
/// every alarm, ring and re-ring call on the real channel and replaces only /// every alarm, ring and re-ring call on the real channel and replaces only
/// the three inputs hardware would supply: /// the two inputs hardware would supply:
/// ///
/// - `files/tag` — a tag id; consumed once, delivered to the NFC listener /// - `files/tag` — a tag id; consumed once, delivered to the NFC listener
/// - `files/steps` — the step count the ring's walk stage reads /// - `files/steps` — the step count the ring's walk stage reads
/// - `files/idle_ms` — how long the phone has "lain still"
/// ///
/// under `/sdcard/Android/data/<package>/files/`. Probe-only: `lib/main.dart` /// under `/sdcard/Android/data/<package>/files/`. Probe-only: `lib/main.dart`
/// never constructs it. /// never constructs it.
@ -67,10 +66,4 @@ class FileDrivenScheduler extends AlarmScheduler {
@override @override
Future<int> stepsSinceRingStart() async => Future<int> stepsSinceRingStart() async =>
int.tryParse(await _read('steps') ?? '') ?? -1; int.tryParse(await _read('steps') ?? '') ?? -1;
@override
Future<Duration?> motionIdle() async {
final ms = int.tryParse(await _read('idle_ms') ?? '');
return ms == null ? null : Duration(milliseconds: ms);
}
} }

View File

@ -52,8 +52,8 @@ class SessionBody extends StatelessWidget {
Text('Stay up', style: theme.textTheme.headlineMedium), Text('Stay up', style: theme.textTheme.headlineMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'The alarm is off. It comes back if a step below is missed, ' 'The alarm is off. It comes back if a step below is missed. '
'or if the phone lies still.', 'Leaving the app is fine — the notification brings you back.',
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@ -5,6 +5,7 @@ import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/screens/session_body.dart'; import 'package:wake_alarm_sync/screens/session_body.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/morning_session.dart'; import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_notice.dart';
import 'package:wake_alarm_sync/services/session_publisher.dart'; import 'package:wake_alarm_sync/services/session_publisher.dart';
import 'package:wake_alarm_sync/services/session_rules.dart'; import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/session_store.dart';
@ -14,9 +15,6 @@ import 'package:wake_alarm_sync/services/weight_entry.dart';
/// How often the clocks advance and the countdown redraws. /// How often the clocks advance and the countdown redraws.
const Duration kSessionTick = Duration(seconds: 1); const Duration kSessionTick = Duration(seconds: 1);
/// Every this many ticks the motion watch is read.
const int kMotionPollEvery = 5;
/// Logs to logcat in release too: `developer.log` is invisible there, and a /// Logs to logcat in release too: `developer.log` is invisible there, and a
/// morning that went wrong on the phone is only ever debugged from logcat. /// morning that went wrong on the phone is only ever debugged from logcat.
void sessionLog(String message) => debugPrint('WakeAlarmSession: $message'); void sessionLog(String message) => debugPrint('WakeAlarmSession: $message');
@ -25,9 +23,10 @@ void sessionLog(String message) => debugPrint('WakeAlarmSession: $message');
/// ///
/// Shown in `AlarmActivity` in place of the ring screen, so NFC reader mode /// Shown in `AlarmActivity` in place of the ring screen, so NFC reader mode
/// and the kept-on screen carry over. Nothing here rings by itself; the /// and the kept-on screen carry over. Nothing here rings by itself; the
/// deadlines are AlarmManager alarms armed through [AlarmScheduler.armReRing] /// deadlines are AlarmManager alarms armed through [pointNativeAtLeg] and
/// and this widget merely keeps them pointed at the live leg. What it does /// this widget merely keeps them, and the ongoing notification, pointed at
/// decide is the still-phone ring, which needs a live sensor. /// the live leg. Leaving the app is allowed (a podcast before the shower):
/// the launcher and the notification both come back here.
class SessionScreen extends StatefulWidget { class SessionScreen extends StatefulWidget {
/// Creates the screen; every dependency is injectable for tests. /// Creates the screen; every dependency is injectable for tests.
const SessionScreen({ const SessionScreen({
@ -67,7 +66,6 @@ class _SessionScreenState extends State<SessionScreen> {
double? _lastWeight; double? _lastWeight;
double? _confirming; double? _confirming;
Timer? _timer; Timer? _timer;
int _ticks = 0;
bool _finishing = false; bool _finishing = false;
String _status = ''; String _status = '';
@ -78,11 +76,22 @@ class _SessionScreenState extends State<SessionScreen> {
} }
/// Loads the session the ring screen left, or starts one if reached cold. /// Loads the session the ring screen left, or starts one if reached cold.
///
/// Cold with today's session already in history is a stale native record
/// (`SessionNotice.kt`) that redirected a launch here: one session per
/// day, so clear it and close rather than start the morning over.
Future<void> _init() async { Future<void> _init() async {
final tags = await SessionTagSettings.load(); final tags = await SessionTagSettings.load();
final now = _now(); final now = _now();
var session = await _store.loadCurrent(); var session = await _store.loadCurrent();
if (session == null) { if (session == null) {
final today = SessionRules.dateKey(now);
if ((await _store.history()).any((s) => s.date == today)) {
sessionLog('already finished today; closing');
await _scheduler.finishSession();
await _scheduler.closeRingScreen();
return;
}
session = SessionRules.start( session = SessionRules.start(
now: now, now: now,
tags: tags, tags: tags,
@ -93,8 +102,7 @@ class _SessionScreenState extends State<SessionScreen> {
await _store.save(session); await _store.save(session);
} }
final last = await _store.lastWeightKg(); final last = await _store.lastWeightKg();
await _scheduler.beginMotionWatch(); await pointNativeAtLeg(_scheduler, session);
await _scheduler.armReRing(session.nextDeadline());
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_tags = tags; _tags = tags;
@ -143,7 +151,7 @@ class _SessionScreenState extends State<SessionScreen> {
Future<void> _commit(String status) async { Future<void> _commit(String status) async {
final session = _session!; final session = _session!;
await _store.save(session); await _store.save(session);
await _scheduler.armReRing(session.nextDeadline()); await pointNativeAtLeg(_scheduler, session);
if (!mounted) return; if (!mounted) return;
setState(() => _status = status); setState(() => _status = status);
if (!session.isActive) await _finish(); if (!session.isActive) await _finish();
@ -157,19 +165,17 @@ class _SessionScreenState extends State<SessionScreen> {
if (tick.changed) { if (tick.changed) {
// A bypass passes a leg, so the deadline moves with it. // A bypass passes a leg, so the deadline moves with it.
await _store.save(session); await _store.save(session);
await _scheduler.armReRing(session.nextDeadline()); await pointNativeAtLeg(_scheduler, session);
} }
if (tick.ringReason != null) { if (tick.ringReason != null) {
await _ring(tick.ringReason!, now); await _ring(tick.ringReason!, now);
return; return;
} }
_ticks++; // The native deadline already rang (see [AlarmScheduler.isRinging]):
if (_ticks % kMotionPollEvery == 0) { // the dismiss sequence belongs in front now, not after the 20 s grace.
final idle = await _scheduler.motionIdle(); if (session.isActive && await _scheduler.isRinging()) {
if (session.stillnessRingDue(idle)) { await _ring('deadline:${session.currentLeg!.name}', now);
await _ring('still:${session.currentLeg!.name}', now); return;
return;
}
} }
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {

View File

@ -148,23 +148,43 @@ class AlarmScheduler {
<String, int?>{'atMillis': at?.millisecondsSinceEpoch}, <String, int?>{'atMillis': at?.millisecondsSinceEpoch},
); );
/// Ends the session: drops the pending re-ring and the motion watch. /// Ends the session: drops the pending re-ring and the session notice.
/// ///
/// The only path that clears a re-ring. Called from the session's own /// The only path that clears a re-ring. Called from the session's own
/// finish, never from a button — the wake alarm still has no off switch. /// finish, never from a button — the wake alarm still has no off switch.
Future<void> finishSession() => _channel.invokeMethod<void>('finishSession'); Future<void> finishSession() => _channel.invokeMethod<void>('finishSession');
/// Starts sampling the accelerometer; the idle clock starts now. /// Hands the native side the live leg: [date] (`yyyy-mm-dd`), its [label]
Future<void> beginMotionWatch() => /// and the instant it re-rings at, or null when it has no deadline.
_channel.invokeMethod<void>('beginMotionWatch');
/// How long the phone has lain still, or null when nothing is watching.
/// ///
/// Null is "unknown", never "still": a missing sensor must not re-ring. /// This is what the launcher redirect and the ongoing notification read
Future<Duration?> motionIdle() async { /// (`SessionNotice.kt`), so it must follow every leg change; the
final millis = await _channel.invokeMethod<int>('motionIdleMillis') ?? -1; /// notification counts down by itself, so it need not follow the tick.
return millis < 0 ? null : Duration(milliseconds: millis); Future<void> updateSessionNotice({
} required String date,
required String label,
DateTime? deadline,
}) => _channel.invokeMethod<void>('updateSessionNotice', <String, Object?>{
'date': date,
'label': label,
'deadlineMillis': deadline?.millisecondsSinceEpoch,
});
/// Whether a launch right now is redirected into the morning: a ring is
/// sounding, or today's session is still on a leg. The probes ask before
/// starting a test ring on top of it.
Future<bool> morningLive() async =>
await _channel.invokeMethod<bool>('morningLive') ?? false;
/// Whether the ring is sounding right now.
///
/// The session screen polls this every tick. A deadline that fires while
/// the checklist sits in the background is a heads-up notification plus
/// sound (an unlocked, in-use phone downgrades the full-screen intent) and
/// the live `singleInstance` activity is not recreated, so nothing else
/// would put the dismiss sequence in front of the noise.
Future<bool> isRinging() async =>
await _channel.invokeMethod<bool>('isRinging') ?? false;
/// Raw presence observations: `ssid`, `bssid`, `lat`, `lon`, `accuracy`, /// Raw presence observations: `ssid`, `bssid`, `lat`, `lon`, `accuracy`,
/// `distanceMeters`, `reason`. /// `distanceMeters`, `reason`.

View File

@ -108,9 +108,6 @@ class MorningSession {
/// your feet — never a silent skip. /// your feet — never a silent skip.
static const Duration bypassGrace = Duration(minutes: 5); static const Duration bypassGrace = Duration(minutes: 5);
/// A phone that has not moved for this long is on a nightstand.
static const Duration stillnessLimit = Duration(minutes: 4);
/// Re-rings per morning before the session gives up. /// Re-rings per morning before the session gives up.
static const int maxReRings = 3; static const int maxReRings = 3;
@ -147,7 +144,7 @@ class MorningSession {
/// Re-rings dismissed so far this session. /// Re-rings dismissed so far this session.
int reRings; int reRings;
/// Why each re-ring happened, e.g. `still:dressed`, `deadline:atDesk`. /// Why each re-ring happened, e.g. `deadline:atDesk`.
final List<String> reRingReasons; final List<String> reRingReasons;
/// Set by the driver before it rings, so the dismiss can name the reason. /// Set by the driver before it rings, so the dismiss can name the reason.
@ -188,9 +185,6 @@ class MorningSession {
/// Whether another re-ring is allowed. /// Whether another re-ring is allowed.
bool get canReRing => reRings < maxReRings; bool get canReRing => reRings < maxReRings;
/// Whether the still-phone check is off: it lies on a shelf in the shower.
bool get stillnessSuspended => currentLeg == SessionLeg.showerDone;
/// Whether this session still needs driving. /// Whether this session still needs driving.
bool get isActive => outcome == SessionOutcome.active; bool get isActive => outcome == SessionOutcome.active;
} }

View File

@ -0,0 +1,44 @@
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
/// Keeps the native side pointed at the live leg.
///
/// One call re-arms the deadline (`armReRing`) and rewrites the native
/// session record behind the launcher redirect and the ongoing notification
/// (`updateSessionNotice`). They are one operation on purpose: a leg whose
/// deadline moved but whose notice did not would show a countdown to an
/// instant nothing rings at, and the reverse would let the notification
/// promise a ring the alarm never arms.
///
/// Called on every change to the session, never per tick -- the countdown
/// is the notification's own.
///
/// A session that is over gets no notice: the finish path clears the record
/// and the notification, and this must not race it back into existence --
/// the tick that noticed the last leg pass ran `_finish` while the tag's
/// own commit was still on its way here (emulator, 2026-09-17), and the
/// stale "Done" record then redirected the next launch into a second
/// session for the day.
Future<void> pointNativeAtLeg(
AlarmScheduler scheduler,
MorningSession session,
) async {
final deadline = session.nextDeadline();
await scheduler.armReRing(deadline);
if (!session.isActive) return;
await scheduler.updateSessionNotice(
date: session.date,
label: noticeLabel(session),
deadline: deadline,
);
}
/// The notification's one line: the leg to satisfy, or what happens instead.
String noticeLabel(MorningSession session) {
final leg = session.currentLeg!;
if (session.deadlineRung.contains(leg)) {
return '${leg.label} — passes by itself soon';
}
return leg.label;
}

View File

@ -12,9 +12,8 @@ class SessionTick {
/// Why to ring right now, or null. /// Why to ring right now, or null.
/// ///
/// Set only for a stillness ring or a deadline the native alarm should /// Set only for a deadline the native alarm should already have fired —
/// already have fired — calling the ring twice is harmless, missing it is /// calling the ring twice is harmless, missing it is not.
/// not.
final String? ringReason; final String? ringReason;
/// Something to show, or null. /// Something to show, or null.
@ -141,14 +140,6 @@ extension SessionRules on MorningSession {
return SessionTick(ringReason: 'deadline:${leg.name}'); return SessionTick(ringReason: 'deadline:${leg.name}');
} }
/// Whether the phone has lain still long enough to ring.
bool stillnessRingDue(Duration? idle) =>
isActive &&
canReRing &&
!stillnessSuspended &&
idle != null &&
idle >= MorningSession.stillnessLimit;
/// Names the re-ring the driver is about to trigger. /// Names the re-ring the driver is about to trigger.
void markReRing(String reason, DateTime now) { void markReRing(String reason, DateTime now) {
pendingReRingReason = reason; pendingReRingReason = reason;

View File

@ -8,8 +8,10 @@ and types the challenge answers it reads back from logcat. Everything else
scripts/emu_morning.py dismiss # tag 1, walk, tag 2, 3 answers scripts/emu_morning.py dismiss # tag 1, walk, tag 2, 3 answers
scripts/emu_morning.py tag bath # one session tag scripts/emu_morning.py tag bath # one session tag
scripts/emu_morning.py idle 300000 # the phone "lay still" 5 min scripts/emu_morning.py rering # fire the deadline alarm now
scripts/emu_morning.py weight 72.4 # type a weight and submit scripts/emu_morning.py weight 72.4 # type a weight and submit
scripts/emu_morning.py home # leave the app (home gesture)
scripts/emu_morning.py launch # tap the launcher icon
scripts/emu_morning.py shot out.png # screenshot scripts/emu_morning.py shot out.png # screenshot
Reads answers from a ``flutter run`` log (``--log``) or from ``adb logcat``. Reads answers from a ``flutter run`` log (``--log``) or from ``adb logcat``.
@ -63,6 +65,42 @@ def write_file(serial: str, name: str, content: str) -> None:
adb(serial, "shell", f"mkdir -p {FILES} && printf %s '{content}' > {FILES}/{name}") adb(serial, "shell", f"mkdir -p {FILES} && printf %s '{content}' > {FILES}/{name}")
def rering(serial: str) -> None:
"""Delivers the session-deadline broadcast, as AlarmManager would.
Targets the receiver by component, so there is no waiting for a real
ten-minute window. The receiver is not exported, so the shell user's
broadcast is silently dropped (``result=0`` and nothing happens); adbd
has to run as root, which a Google-APIs AVD allows and a Play one does
not. Idempotent, so it is done here rather than left to the caller.
"""
adb(serial, "root")
adb(serial, "wait-for-device")
adb(
serial,
"shell",
"am broadcast -n",
f"{PACKAGE}/.AlarmReceiver",
"--es reason rering",
)
def home(serial: str) -> None:
"""Backgrounds whatever is in front, as the home gesture does."""
adb(serial, "shell", "input keyevent HOME")
def launch(serial: str) -> None:
"""Starts the app from its launcher entry, as a tap on the icon does."""
adb(
serial,
"shell",
"monkey -p",
PACKAGE,
"-c android.intent.category.LAUNCHER 1",
)
def screenshot(serial: str, path: Path) -> None: def screenshot(serial: str, path: Path) -> None:
"""Captures the screen as PNG to [path].""" """Captures the screen as PNG to [path]."""
path.write_bytes(adb(serial, "exec-out", "screencap", "-p")) path.write_bytes(adb(serial, "exec-out", "screencap", "-p"))
@ -172,7 +210,9 @@ def main(argv: list[str]) -> int:
sub = parser.add_subparsers(dest="cmd", required=True) sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("dismiss") sub.add_parser("dismiss")
sub.add_parser("tag").add_argument("id") sub.add_parser("tag").add_argument("id")
sub.add_parser("idle").add_argument("ms") sub.add_parser("rering")
sub.add_parser("home")
sub.add_parser("launch")
sub.add_parser("weight").add_argument("kg") sub.add_parser("weight").add_argument("kg")
sub.add_parser("shot").add_argument("path", type=Path) sub.add_parser("shot").add_argument("path", type=Path)
args = parser.parse_args(argv) args = parser.parse_args(argv)
@ -180,8 +220,12 @@ def main(argv: list[str]) -> int:
dismiss(args.serial, args.log) dismiss(args.serial, args.log)
elif args.cmd == "tag": elif args.cmd == "tag":
write_file(args.serial, "tag", args.id) write_file(args.serial, "tag", args.id)
elif args.cmd == "idle": elif args.cmd == "rering":
write_file(args.serial, "idle_ms", args.ms) rering(args.serial)
elif args.cmd == "home":
home(args.serial)
elif args.cmd == "launch":
launch(args.serial)
elif args.cmd == "weight": elif args.cmd == "weight":
type_and_submit(args.serial, args.kg) type_and_submit(args.serial, args.kg)
elif args.cmd == "shot": elif args.cmd == "shot":

View File

@ -7,9 +7,9 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart';
/// Half of the cross-language gate (`scripts/free_days_interop/check.sh`). /// Half of the cross-language gate (`scripts/free_days_interop/check.sh`).
/// ///
/// Writes a pool through the Dart rules to `FREE_DAYS_INTEROP_OUT`, then the /// Writes a pool through the Dart rules to `FREE_DAYS_INTEROP_OUT`, then the
/// script replays the same edits through the Python package and compares. Under a plain /// script replays the same edits through the Python package and compares.
/// `flutter test` the variable is unset and this does nothing: the point is /// Under a plain `flutter test` the variable is unset and this does nothing:
/// the comparison, and only the script can make it. /// the point is the comparison, and only the script can make it.
class _FilePersistence implements LogPersistence { class _FilePersistence implements LogPersistence {
_FilePersistence(this.file); _FilePersistence(this.file);

View File

@ -80,4 +80,35 @@ void main() {
test('stepsSinceRingStart reports -1 when the platform gives null', () async { test('stepsSinceRingStart reports -1 when the platform gives null', () async {
expect(await scheduler.stepsSinceRingStart(), -1); expect(await scheduler.stepsSinceRingStart(), -1);
}); });
test('updateSessionNotice carries date, label and the deadline', () async {
final at = DateTime(2026, 9, 17, 8, 45);
await scheduler.updateSessionNotice(
date: '2026-09-17',
label: 'Tap the bathroom tag',
deadline: at,
);
expect(calls.single.method, 'updateSessionNotice');
expect(calls.single.arguments, <String, Object?>{
'date': '2026-09-17',
'label': 'Tap the bathroom tag',
'deadlineMillis': at.millisecondsSinceEpoch,
});
});
test('updateSessionNotice sends null for a leg with no deadline', () async {
await scheduler.updateSessionNotice(date: '2026-09-17', label: 'x');
expect(
(calls.single.arguments as Map<Object?, Object?>)['deadlineMillis'],
isNull,
);
});
test('morningLive and isRinging fail closed on a null result', () async {
expect(await scheduler.morningLive(), isFalse);
expect(await scheduler.isRinging(), isFalse);
reply = true;
expect(await scheduler.morningLive(), isTrue);
expect(await scheduler.isRinging(), isTrue);
});
} }

View File

@ -0,0 +1,81 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_notice.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
final DateTime t0 = DateTime(2026, 9, 17, 8, 5);
const SessionTagSettings allTags = SessionTagSettings(
tagIds: <String>['bath', 'ward', 'desk'],
);
MorningSession fresh() =>
SessionRules.start(now: t0, tags: allTags, nfcAvailable: true);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late List<MethodCall> calls;
late AlarmScheduler scheduler;
setUp(() {
calls = <MethodCall>[];
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return null;
});
scheduler = AlarmScheduler(channel: channel);
});
test('the label is the live leg', () {
expect(noticeLabel(fresh()), 'Tap the bathroom tag');
});
test('a leg whose re-ring is spent says it will pass by itself', () {
final s = fresh()..deadlineRung.add(SessionLeg.bathroomIn);
expect(noticeLabel(s), 'Tap the bathroom tag — passes by itself soon');
});
test('a finished session drops the deadline and posts no notice', () async {
final s = fresh()
..enterWeight(72, t0)
..onTag(SessionTagSlot.bathroom, t0)
..onTag(SessionTagSlot.bathroom, t0.add(const Duration(minutes: 9)))
..onTag(SessionTagSlot.wardrobe, t0)
..onTag(SessionTagSlot.desk, t0);
expect(s.isActive, isFalse);
await pointNativeAtLeg(scheduler, s);
expect(calls.map((c) => c.method), <String>['armReRing']);
expect(calls.single.arguments, <String, int?>{'atMillis': null});
});
test('pointNativeAtLeg arms the deadline and the notice together', () async {
final s = fresh();
await pointNativeAtLeg(scheduler, s);
expect(calls.map((c) => c.method), <String>[
'armReRing',
'updateSessionNotice',
]);
final deadline = s.nextDeadline()!.millisecondsSinceEpoch;
expect(calls[0].arguments, <String, int?>{'atMillis': deadline});
expect(calls[1].arguments, <String, Object?>{
'date': '2026-09-17',
'label': 'Tap the bathroom tag',
'deadlineMillis': deadline,
});
});
test('a spent re-ring sends no deadline to either side', () async {
final s = fresh()..deadlineRung.add(SessionLeg.bathroomIn);
await pointNativeAtLeg(scheduler, s);
expect(calls[0].arguments, <String, int?>{'atMillis': null});
expect(
(calls[1].arguments as Map<Object?, Object?>)['deadlineMillis'],
isNull,
);
});
}

View File

@ -136,7 +136,6 @@ void main() {
s.deadline(SessionLeg.bathroomIn), s.deadline(SessionLeg.bathroomIn),
t0.add(const Duration(minutes: 10)), t0.add(const Duration(minutes: 10)),
); );
expect(s.stillnessSuspended, isTrue);
}); });
}); });
@ -178,30 +177,17 @@ void main() {
}); });
test( test(
'a stillness ring keeps its reason and does not spend the deadline', 'a ring before the deadline keeps its reason and does not spend it',
() { () {
final s = fresh() final s = fresh()
..markReRing('still:bathroomIn', t0) ..markReRing('deadline:bathroomIn', t0)
..recordReRing(t0.add(const Duration(minutes: 3))); ..recordReRing(t0.add(const Duration(minutes: 3)));
expect(s.reRingReasons, <String>['still:bathroomIn']); expect(s.reRingReasons, <String>['deadline:bathroomIn']);
expect(s.deadlineRung, isEmpty); expect(s.deadlineRung, isEmpty);
expect(s.nextDeadline(), isNotNull); expect(s.nextDeadline(), isNotNull);
}, },
); );
test('stillness is due only when live, allowed and not showering', () {
final s = fresh();
expect(s.stillnessRingDue(const Duration(minutes: 4)), isTrue);
expect(s.stillnessRingDue(const Duration(minutes: 3)), isFalse);
expect(s.stillnessRingDue(null), isFalse);
expect(
inBathroom().stillnessRingDue(const Duration(minutes: 9)),
isFalse,
);
s.reRings = MorningSession.maxReRings;
expect(s.stillnessRingDue(const Duration(minutes: 9)), isFalse);
});
test('a miss with no re-rings left fails the session', () { test('a miss with no re-rings left fails the session', () {
final s = fresh()..reRings = MorningSession.maxReRings; final s = fresh()..reRings = MorningSession.maxReRings;
expect(s.nextDeadline(), isNull); expect(s.nextDeadline(), isNull);

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/session_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.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/session_store.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
/// The session screen reached cold by the launcher redirect, not by a ring.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late List<MethodCall> calls;
final now = DateTime(2026, 9, 17, 8, 40);
setUp(() async {
calls = <MethodCall>[];
SharedPreferences.setMockInitialValues(<String, Object>{});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(AlarmScheduler.channelName),
(call) async {
calls.add(call);
return call.method == 'hasNfc' ? true : null;
},
);
await SessionTagSettings.replaceAll(<String>['bath', 'ward', 'desk'], '');
});
Future<void> pumpCold(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SessionScreen(clock: () => now, publish: () async => 'ok'),
),
);
await tester.pump();
await tester.pump();
}
testWidgets('resumes the persisted session at its live leg', (tester) async {
final at = now.subtract(const Duration(minutes: 4));
final session =
SessionRules.start(
now: now.subtract(const Duration(minutes: 5)),
tags: await SessionTagSettings.load(),
nfcAvailable: true,
)
..enterWeight(72, at)
..onTag(SessionTagSlot.bathroom, at);
await SessionStore().save(session);
await pumpCold(tester);
expect(find.text('Weigh in — 72.0 kg'), findsOneWidget);
expect(find.textContaining('tag counts in'), findsOneWidget);
final notice = calls.lastWhere((c) => c.method == 'updateSessionNotice');
expect(
(notice.arguments as Map<Object?, Object?>)['label'],
'Shower, then tap the bathroom tag again',
);
expect(calls.map((c) => c.method), isNot(contains('closeRingScreen')));
});
testWidgets('with today already finished it clears the record and closes', (
tester,
) async {
final done = SessionRules.start(
now: now.subtract(const Duration(minutes: 30)),
tags: const SessionTagSettings(),
nfcAvailable: false,
)..enterWeight(72, now.subtract(const Duration(minutes: 29)));
expect(done.outcome, SessionOutcome.completed);
await SessionStore().archive(done);
await pumpCold(tester);
final methods = calls.map((c) => c.method).toList();
expect(methods, contains('finishSession'));
expect(methods.last, 'closeRingScreen');
expect(methods, isNot(contains('updateSessionNotice')));
expect(await SessionStore().loadCurrent(), isNull);
expect((await SessionStore().history()).length, 1);
});
}

View File

@ -14,7 +14,7 @@ void main() {
late List<MethodCall> calls; late List<MethodCall> calls;
late DateTime now; late DateTime now;
int? idleMs; var ringing = false;
var published = 0; var published = 0;
void mockChannel() { void mockChannel() {
@ -24,7 +24,7 @@ void main() {
(call) async { (call) async {
calls.add(call); calls.add(call);
if (call.method == 'hasNfc') return true; if (call.method == 'hasNfc') return true;
if (call.method == 'motionIdleMillis') return idleMs ?? -1; if (call.method == 'isRinging') return ringing;
return null; return null;
}, },
); );
@ -53,7 +53,7 @@ void main() {
setUp(() async { setUp(() async {
calls = <MethodCall>[]; calls = <MethodCall>[];
now = DateTime(2026, 9, 17, 8, 4); now = DateTime(2026, 9, 17, 8, 4);
idleMs = null; ringing = false;
published = 0; published = 0;
SharedPreferences.setMockInitialValues(<String, Object>{}); SharedPreferences.setMockInitialValues(<String, Object>{});
mockChannel(); mockChannel();
@ -92,14 +92,33 @@ void main() {
expect(find.text('Stay up'), findsOneWidget); expect(find.text('Stay up'), findsOneWidget);
expect(find.text('Weigh in'), findsOneWidget); expect(find.text('Weigh in'), findsOneWidget);
expect(find.text('Alarm returns in 10:00'), findsOneWidget); expect(find.text('Alarm returns in 10:00'), findsOneWidget);
expect(methods(), contains('beginMotionWatch')); final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch;
final arm = calls.firstWhere((c) => c.method == 'armReRing'); final arm = calls.firstWhere((c) => c.method == 'armReRing');
expect(arm.arguments, <String, int?>{ expect(arm.arguments, <String, int?>{'atMillis': deadline});
'atMillis': now.add(MorningSession.legWindow).millisecondsSinceEpoch, // The native record behind the launcher redirect and the notification
// follows the deadline in the same breath.
final notice = calls.firstWhere((c) => c.method == 'updateSessionNotice');
expect(notice.arguments, <String, Object?>{
'date': '2026-09-17',
'label': 'Tap the bathroom tag',
'deadlineMillis': deadline,
}); });
expect((await SessionStore().loadCurrent())?.date, '2026-09-17'); expect((await SessionStore().loadCurrent())?.date, '2026-09-17');
}); });
testWidgets('every leg change re-points the native notice', (tester) async {
await pumpSession(tester);
await logWeight(tester, '72');
await tap('bath');
await tester.pump();
final labels = calls
.where((c) => c.method == 'updateSessionNotice')
.map((c) => (c.arguments as Map<Object?, Object?>)['label'])
.toList();
expect(labels.last, 'Shower, then tap the bathroom tag again');
expect(methods(), isNot(contains('beginMotionWatch')));
});
testWidgets('walks every leg, then finishes, archives and closes', ( testWidgets('walks every leg, then finishes, archives and closes', (
tester, tester,
) async { ) async {
@ -137,7 +156,7 @@ void main() {
expect(done.reRings, 0); expect(done.reRings, 0);
}); });
testWidgets('a still phone rings and routes to the ring screen', ( testWidgets('a native ring already sounding routes to the ring screen', (
tester, tester,
) async { ) async {
await pumpSession(tester); await pumpSession(tester);
@ -146,22 +165,22 @@ void main() {
now = now.add(const Duration(minutes: 9)); now = now.add(const Duration(minutes: 9));
await tap('bath'); await tap('bath');
await tester.pump(); await tester.pump();
idleMs = MorningSession.stillnessLimit.inMilliseconds; ringing = true;
await tick(tester, kMotionPollEvery); await tick(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(methods(), contains('ringNow')); expect(methods(), contains('ringNow'));
expect(find.text('RING'), findsOneWidget); expect(find.text('RING'), findsOneWidget);
final saved = (await SessionStore().loadCurrent())!; final saved = (await SessionStore().loadCurrent())!;
expect(saved.pendingReRingReason, 'still:dressed'); expect(saved.pendingReRingReason, 'deadline:dressed');
expect(saved.currentLeg, SessionLeg.dressed); expect(saved.currentLeg, SessionLeg.dressed);
}); });
testWidgets('stillness is ignored while showering', (tester) async { testWidgets('a silent phone keeps ticking without ringing', (tester) async {
await pumpSession(tester); await pumpSession(tester);
await logWeight(tester, '72'); await logWeight(tester, '72');
await tap('bath'); await tap('bath');
idleMs = MorningSession.stillnessLimit.inMilliseconds * 2; await tick(tester, 5);
await tick(tester, kMotionPollEvery); expect(methods(), contains('isRinging'));
expect(methods(), isNot(contains('ringNow'))); expect(methods(), isNot(contains('ringNow')));
}); });