diff --git a/AGENTS.md b/AGENTS.md index 62e0137..a1387a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 same activity, to the silent **morning session**: weigh in → bathroom tag → 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 -silencing that is the full sequence above again; three re-rings fail the -session. State is persisted after every change and driven by wall-clock -instants, never Dart timers: **`DOCS-morning-session.md`**. +(AlarmManager, request code 1003) re-rings; silencing it is the full sequence +again; three re-rings fail the session. Leaving the app is fine: every launch +while the morning is live lands back on the leg (`SessionNotice.kt`). State is +persisted after every change, on wall-clock instants: **`DOCS-morning-session.md`**. **`HomeScreen.initState` must stay strictly sequential** (`_applyPolicy` → `_loadSyncState` → `_syncToPc`): run concurrently, the startup publish diff --git a/DOCS-morning-session.md b/DOCS-morning-session.md index c9ecc66..45da1a7 100644 --- a/DOCS-morning-session.md +++ b/DOCS-morning-session.md @@ -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` restores it; `restoreReRing` drops an instant that has already passed (see *Gotchas*). -- **A still phone** (accelerometer, `MotionMonitor.kt`, no motion for 4 min) - re-rings from Dart via `ringNow`, then routes itself to `/ring` in case the - full-screen intent lands on the live `singleInstance` activity instead of - recreating it. Suspended during the shower leg — the phone is on a shelf. - Only works while the session screen is up, which is why `AlarmActivity` - now holds `FLAG_KEEP_SCREEN_ON`; the deadline alarm is the backstop. +- **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`. @@ -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 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 @@ -104,8 +141,8 @@ reinstall gets its history back. ## Testing it without a bathroom `lib/main_session_probe.dart` runs the real app on `FileDrivenScheduler` -(`lib/probe/`), which replaces NFC, the step count and the motion watch with -files under `/sdcard/Android/data/com.kuhy.wake_alarm_sync/files/`. +(`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: ```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 weight 72.4 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 tag ward; scripts/emu_morning.py tag desk ``` 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. The phone remains the only test of the AlarmManager deadline with the screen off and the app swiped away. diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt index 5242184..8562165 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt @@ -20,7 +20,19 @@ class AlarmActivity : FlutterActivity() { /** Dart route the ring screen is registered under in main.dart. */ 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?) { // showWhenLocked/turnScreenOn are also set as manifest attributes; the @@ -31,8 +43,8 @@ class AlarmActivity : FlutterActivity() { super.onCreate(savedInstanceState) // The morning session lives in this activity for half an hour after // the ring stops. With the screen allowed to sleep the activity - // pauses, NFC stops polling and the motion watch stops sampling, so - // the only thing left working would be the AlarmManager backstop. + // pauses and NFC stops polling, so a tag tap would need a wake and + // an unlock first, every leg. window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) dismissKeyguard() } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt index b429ffd..9419058 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt @@ -108,14 +108,31 @@ object AlarmChannel { // no off switch. "finishSession" -> { AlarmScheduler.clearReRing(context) - MotionMonitor.stop(context) + SessionNotice.clear(context) result.success(null) } - "beginMotionWatch" -> { - MotionMonitor.start(context) - result.success(null) + // The live leg, for the launcher redirect and the ongoing + // notification; see SessionNotice.kt. Called on every leg + // change, never per tick. + "updateSessionNotice" -> { + val date = call.argument("date") + val label = call.argument("label") + if (date == null || label == null) { + result.error("bad_args", "date and label are required", null) + } else { + val at = call.argument("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" -> { StepCounter.markRingStart(context) result.success(null) diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt index d831866..acc5974 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt @@ -46,6 +46,15 @@ class AlarmService : Service() { /** Vibration: 1s buzz, 1s gap, repeating from index 0. */ 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. */ fun start(context: Context) { val intent = Intent(context, AlarmService::class.java) @@ -54,6 +63,11 @@ class AlarmService : Service() { /** Stops the ring and releases every resource it holds. */ 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)) } } @@ -72,12 +86,14 @@ class AlarmService : Service() { acquireWakeLock() startSound() startVibration() + running = true // START_STICKY: if the system kills us under memory pressure while the // user is still asleep, the alarm comes back rather than going silent. return START_STICKY } override fun onDestroy() { + running = false stopSound() stopVibration() releaseWakeLock() diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt index 6b46294..9f72baf 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt @@ -20,9 +20,28 @@ class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (resumeMorning()) return 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. * @@ -114,6 +133,7 @@ class MainActivity : FlutterActivity() { override fun onResume() { super.onResume() + if (resumeMorning()) return if (registeringTag) alarmChannel?.let { NfcReader.start(this, it) } } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MotionMonitor.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MotionMonitor.kt deleted file mode 100644 index 1076f66..0000000 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MotionMonitor.kt +++ /dev/null @@ -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 - } -} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionNotice.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionNotice.kt new file mode 100644 index 0000000..c699eb0 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionNotice.kt @@ -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() + } +} diff --git a/phone_app/lib/main.dart b/phone_app/lib/main.dart index 97b5d87..735b86e 100644 --- a/phone_app/lib/main.dart +++ b/phone_app/lib/main.dart @@ -54,8 +54,11 @@ class WakeAlarmSyncApp extends StatelessWidget { routes: { '/': (_) => const HomeScreen(), kRingRoute: (_) => RingScreen(scheduler: newScheduler?.call()), - // Reached only by pushReplacement from the ring screen, inside - // AlarmActivity; it is never an initial route. + // Reached by pushReplacement from the ring screen, or as + // 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()), }, ); diff --git a/phone_app/lib/main_session_probe.dart b/phone_app/lib/main_session_probe.dart index d74c89a..322ffc5 100644 --- a/phone_app/lib/main_session_probe.dart +++ b/phone_app/lib/main_session_probe.dart @@ -8,9 +8,11 @@ import 'package:wake_alarm_sync/services/session_tag_settings.dart'; /// Emulator entrypoint: the whole morning, driven from adb. /// -/// Like `main_ring_probe.dart` it rings on launch, 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: +/// Like `main_ring_probe.dart` it rings on launch -- unless the morning is +/// already live, in which case the launch is redirected into it exactly as +/// 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 /// F=/sdcard/Android/data/com.kuhy.wake_alarm_sync/files @@ -27,6 +29,7 @@ Future main() async { await SessionTagSettings.saveTagAt(SessionTagSlot.bathroom, 'bath'); await SessionTagSettings.saveTagAt(SessionTagSlot.wardrobe, 'ward'); 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)); } diff --git a/phone_app/lib/probe/file_driven_scheduler.dart b/phone_app/lib/probe/file_driven_scheduler.dart index 62331d0..78f4cda 100644 --- a/phone_app/lib/probe/file_driven_scheduler.dart +++ b/phone_app/lib/probe/file_driven_scheduler.dart @@ -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 /// 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 -/// 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/steps` — the step count the ring's walk stage reads -/// - `files/idle_ms` — how long the phone has "lain still" /// /// under `/sdcard/Android/data//files/`. Probe-only: `lib/main.dart` /// never constructs it. @@ -67,10 +66,4 @@ class FileDrivenScheduler extends AlarmScheduler { @override Future stepsSinceRingStart() async => int.tryParse(await _read('steps') ?? '') ?? -1; - - @override - Future motionIdle() async { - final ms = int.tryParse(await _read('idle_ms') ?? ''); - return ms == null ? null : Duration(milliseconds: ms); - } } diff --git a/phone_app/lib/screens/session_body.dart b/phone_app/lib/screens/session_body.dart index 0549d02..85ddcd5 100644 --- a/phone_app/lib/screens/session_body.dart +++ b/phone_app/lib/screens/session_body.dart @@ -52,8 +52,8 @@ class SessionBody extends StatelessWidget { Text('Stay up', style: theme.textTheme.headlineMedium), const SizedBox(height: 4), Text( - 'The alarm is off. It comes back if a step below is missed, ' - 'or if the phone lies still.', + 'The alarm is off. It comes back if a step below is missed. ' + 'Leaving the app is fine — the notification brings you back.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 24), diff --git a/phone_app/lib/screens/session_screen.dart b/phone_app/lib/screens/session_screen.dart index d07fc0c..e7d1bbd 100644 --- a/phone_app/lib/screens/session_screen.dart +++ b/phone_app/lib/screens/session_screen.dart @@ -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/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_publisher.dart'; import 'package:wake_alarm_sync/services/session_rules.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. 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 /// morning that went wrong on the phone is only ever debugged from logcat. 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 /// and the kept-on screen carry over. Nothing here rings by itself; the -/// deadlines are AlarmManager alarms armed through [AlarmScheduler.armReRing] -/// and this widget merely keeps them pointed at the live leg. What it does -/// decide is the still-phone ring, which needs a live sensor. +/// deadlines are AlarmManager alarms armed through [pointNativeAtLeg] and +/// this widget merely keeps them, and the ongoing notification, pointed at +/// 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 { /// Creates the screen; every dependency is injectable for tests. const SessionScreen({ @@ -67,7 +66,6 @@ class _SessionScreenState extends State { double? _lastWeight; double? _confirming; Timer? _timer; - int _ticks = 0; bool _finishing = false; String _status = ''; @@ -78,11 +76,22 @@ class _SessionScreenState extends State { } /// 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 _init() async { final tags = await SessionTagSettings.load(); final now = _now(); var session = await _store.loadCurrent(); 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( now: now, tags: tags, @@ -93,8 +102,7 @@ class _SessionScreenState extends State { await _store.save(session); } final last = await _store.lastWeightKg(); - await _scheduler.beginMotionWatch(); - await _scheduler.armReRing(session.nextDeadline()); + await pointNativeAtLeg(_scheduler, session); if (!mounted) return; setState(() { _tags = tags; @@ -143,7 +151,7 @@ class _SessionScreenState extends State { Future _commit(String status) async { final session = _session!; await _store.save(session); - await _scheduler.armReRing(session.nextDeadline()); + await pointNativeAtLeg(_scheduler, session); if (!mounted) return; setState(() => _status = status); if (!session.isActive) await _finish(); @@ -157,19 +165,17 @@ class _SessionScreenState extends State { if (tick.changed) { // A bypass passes a leg, so the deadline moves with it. await _store.save(session); - await _scheduler.armReRing(session.nextDeadline()); + await pointNativeAtLeg(_scheduler, session); } if (tick.ringReason != null) { await _ring(tick.ringReason!, now); return; } - _ticks++; - if (_ticks % kMotionPollEvery == 0) { - final idle = await _scheduler.motionIdle(); - if (session.stillnessRingDue(idle)) { - await _ring('still:${session.currentLeg!.name}', now); - return; - } + // The native deadline already rang (see [AlarmScheduler.isRinging]): + // the dismiss sequence belongs in front now, not after the 20 s grace. + if (session.isActive && await _scheduler.isRinging()) { + await _ring('deadline:${session.currentLeg!.name}', now); + return; } if (!mounted) return; setState(() { diff --git a/phone_app/lib/services/alarm_scheduler.dart b/phone_app/lib/services/alarm_scheduler.dart index 856672a..924a5b6 100644 --- a/phone_app/lib/services/alarm_scheduler.dart +++ b/phone_app/lib/services/alarm_scheduler.dart @@ -148,23 +148,43 @@ class AlarmScheduler { {'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 /// finish, never from a button — the wake alarm still has no off switch. Future finishSession() => _channel.invokeMethod('finishSession'); - /// Starts sampling the accelerometer; the idle clock starts now. - Future beginMotionWatch() => - _channel.invokeMethod('beginMotionWatch'); - - /// How long the phone has lain still, or null when nothing is watching. + /// Hands the native side the live leg: [date] (`yyyy-mm-dd`), its [label] + /// and the instant it re-rings at, or null when it has no deadline. /// - /// Null is "unknown", never "still": a missing sensor must not re-ring. - Future motionIdle() async { - final millis = await _channel.invokeMethod('motionIdleMillis') ?? -1; - return millis < 0 ? null : Duration(milliseconds: millis); - } + /// This is what the launcher redirect and the ongoing notification read + /// (`SessionNotice.kt`), so it must follow every leg change; the + /// notification counts down by itself, so it need not follow the tick. + Future updateSessionNotice({ + required String date, + required String label, + DateTime? deadline, + }) => _channel.invokeMethod('updateSessionNotice', { + '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 morningLive() async => + await _channel.invokeMethod('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 isRinging() async => + await _channel.invokeMethod('isRinging') ?? false; /// Raw presence observations: `ssid`, `bssid`, `lat`, `lon`, `accuracy`, /// `distanceMeters`, `reason`. diff --git a/phone_app/lib/services/morning_session.dart b/phone_app/lib/services/morning_session.dart index 5e45829..d1c24e4 100644 --- a/phone_app/lib/services/morning_session.dart +++ b/phone_app/lib/services/morning_session.dart @@ -108,9 +108,6 @@ class MorningSession { /// your feet — never a silent skip. 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. static const int maxReRings = 3; @@ -147,7 +144,7 @@ class MorningSession { /// Re-rings dismissed so far this session. int reRings; - /// Why each re-ring happened, e.g. `still:dressed`, `deadline:atDesk`. + /// Why each re-ring happened, e.g. `deadline:atDesk`. final List reRingReasons; /// 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. 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. bool get isActive => outcome == SessionOutcome.active; } diff --git a/phone_app/lib/services/session_notice.dart b/phone_app/lib/services/session_notice.dart new file mode 100644 index 0000000..7e5d031 --- /dev/null +++ b/phone_app/lib/services/session_notice.dart @@ -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 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; +} diff --git a/phone_app/lib/services/session_rules.dart b/phone_app/lib/services/session_rules.dart index 40be258..3e582ba 100644 --- a/phone_app/lib/services/session_rules.dart +++ b/phone_app/lib/services/session_rules.dart @@ -12,9 +12,8 @@ class SessionTick { /// Why to ring right now, or null. /// - /// Set only for a stillness ring or a deadline the native alarm should - /// already have fired — calling the ring twice is harmless, missing it is - /// not. + /// Set only for a deadline the native alarm should already have fired — + /// calling the ring twice is harmless, missing it is not. final String? ringReason; /// Something to show, or null. @@ -141,14 +140,6 @@ extension SessionRules on MorningSession { 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. void markReRing(String reason, DateTime now) { pendingReRingReason = reason; diff --git a/phone_app/scripts/emu_morning.py b/phone_app/scripts/emu_morning.py index 825589f..bdc2f10 100755 --- a/phone_app/scripts/emu_morning.py +++ b/phone_app/scripts/emu_morning.py @@ -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 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 home # leave the app (home gesture) + scripts/emu_morning.py launch # tap the launcher icon scripts/emu_morning.py shot out.png # screenshot 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}") +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: """Captures the screen as PNG to [path].""" 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.add_parser("dismiss") 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("shot").add_argument("path", type=Path) args = parser.parse_args(argv) @@ -180,8 +220,12 @@ def main(argv: list[str]) -> int: dismiss(args.serial, args.log) elif args.cmd == "tag": write_file(args.serial, "tag", args.id) - elif args.cmd == "idle": - write_file(args.serial, "idle_ms", args.ms) + elif args.cmd == "rering": + rering(args.serial) + elif args.cmd == "home": + home(args.serial) + elif args.cmd == "launch": + launch(args.serial) elif args.cmd == "weight": type_and_submit(args.serial, args.kg) elif args.cmd == "shot": diff --git a/phone_app/test/free_day_interop_writer_test.dart b/phone_app/test/free_day_interop_writer_test.dart index cfa5689..949bda7 100644 --- a/phone_app/test/free_day_interop_writer_test.dart +++ b/phone_app/test/free_day_interop_writer_test.dart @@ -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`). /// /// 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 -/// `flutter test` the variable is unset and this does nothing: the point is -/// the comparison, and only the script can make it. +/// script replays the same edits through the Python package and compares. +/// Under a plain `flutter test` the variable is unset and this does nothing: +/// the point is the comparison, and only the script can make it. class _FilePersistence implements LogPersistence { _FilePersistence(this.file); diff --git a/phone_app/test/services/alarm_scheduler_test.dart b/phone_app/test/services/alarm_scheduler_test.dart index e66a24b..fc62fca 100644 --- a/phone_app/test/services/alarm_scheduler_test.dart +++ b/phone_app/test/services/alarm_scheduler_test.dart @@ -80,4 +80,35 @@ void main() { test('stepsSinceRingStart reports -1 when the platform gives null', () async { 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, { + '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)['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); + }); } diff --git a/phone_app/test/services/session_notice_test.dart b/phone_app/test/services/session_notice_test.dart new file mode 100644 index 0000000..15c5d82 --- /dev/null +++ b/phone_app/test/services/session_notice_test.dart @@ -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: ['bath', 'ward', 'desk'], +); + +MorningSession fresh() => + SessionRules.start(now: t0, tags: allTags, nfcAvailable: true); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List calls; + late AlarmScheduler scheduler; + + setUp(() { + calls = []; + 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), ['armReRing']); + expect(calls.single.arguments, {'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), [ + 'armReRing', + 'updateSessionNotice', + ]); + final deadline = s.nextDeadline()!.millisecondsSinceEpoch; + expect(calls[0].arguments, {'atMillis': deadline}); + expect(calls[1].arguments, { + '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, {'atMillis': null}); + expect( + (calls[1].arguments as Map)['deadlineMillis'], + isNull, + ); + }); +} diff --git a/phone_app/test/services/session_rules_test.dart b/phone_app/test/services/session_rules_test.dart index 9df061f..1dcfaa6 100644 --- a/phone_app/test/services/session_rules_test.dart +++ b/phone_app/test/services/session_rules_test.dart @@ -136,7 +136,6 @@ void main() { s.deadline(SessionLeg.bathroomIn), t0.add(const Duration(minutes: 10)), ); - expect(s.stillnessSuspended, isTrue); }); }); @@ -178,30 +177,17 @@ void main() { }); 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() - ..markReRing('still:bathroomIn', t0) + ..markReRing('deadline:bathroomIn', t0) ..recordReRing(t0.add(const Duration(minutes: 3))); - expect(s.reRingReasons, ['still:bathroomIn']); + expect(s.reRingReasons, ['deadline:bathroomIn']); expect(s.deadlineRung, isEmpty); 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', () { final s = fresh()..reRings = MorningSession.maxReRings; expect(s.nextDeadline(), isNull); diff --git a/phone_app/test/session_screen_relaunch_test.dart b/phone_app/test/session_screen_relaunch_test.dart new file mode 100644 index 0000000..acf5b56 --- /dev/null +++ b/phone_app/test/session_screen_relaunch_test.dart @@ -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 calls; + final now = DateTime(2026, 9, 17, 8, 40); + + setUp(() async { + calls = []; + SharedPreferences.setMockInitialValues({}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async { + calls.add(call); + return call.method == 'hasNfc' ? true : null; + }, + ); + await SessionTagSettings.replaceAll(['bath', 'ward', 'desk'], ''); + }); + + Future 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)['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); + }); +} diff --git a/phone_app/test/session_screen_test.dart b/phone_app/test/session_screen_test.dart index 5984cc1..8c4bddc 100644 --- a/phone_app/test/session_screen_test.dart +++ b/phone_app/test/session_screen_test.dart @@ -14,7 +14,7 @@ void main() { late List calls; late DateTime now; - int? idleMs; + var ringing = false; var published = 0; void mockChannel() { @@ -24,7 +24,7 @@ void main() { (call) async { calls.add(call); if (call.method == 'hasNfc') return true; - if (call.method == 'motionIdleMillis') return idleMs ?? -1; + if (call.method == 'isRinging') return ringing; return null; }, ); @@ -53,7 +53,7 @@ void main() { setUp(() async { calls = []; now = DateTime(2026, 9, 17, 8, 4); - idleMs = null; + ringing = false; published = 0; SharedPreferences.setMockInitialValues({}); mockChannel(); @@ -92,14 +92,33 @@ void main() { expect(find.text('Stay up'), findsOneWidget); expect(find.text('Weigh in'), 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'); - expect(arm.arguments, { - 'atMillis': now.add(MorningSession.legWindow).millisecondsSinceEpoch, + expect(arm.arguments, {'atMillis': deadline}); + // 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, { + 'date': '2026-09-17', + 'label': 'Tap the bathroom tag', + 'deadlineMillis': deadline, }); 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)['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', ( tester, ) async { @@ -137,7 +156,7 @@ void main() { 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, ) async { await pumpSession(tester); @@ -146,22 +165,22 @@ void main() { now = now.add(const Duration(minutes: 9)); await tap('bath'); await tester.pump(); - idleMs = MorningSession.stillnessLimit.inMilliseconds; - await tick(tester, kMotionPollEvery); + ringing = true; + await tick(tester); await tester.pumpAndSettle(); expect(methods(), contains('ringNow')); expect(find.text('RING'), findsOneWidget); final saved = (await SessionStore().loadCurrent())!; - expect(saved.pendingReRingReason, 'still:dressed'); + expect(saved.pendingReRingReason, 'deadline: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 logWeight(tester, '72'); await tap('bath'); - idleMs = MorningSession.stillnessLimit.inMilliseconds * 2; - await tick(tester, kMotionPollEvery); + await tick(tester, 5); + expect(methods(), contains('isRinging')); expect(methods(), isNot(contains('ringNow'))); });