mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
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:
parent
c7e7695466
commit
af293413c0
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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<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" -> {
|
||||
StepCounter.markRingStart(context)
|
||||
result.success(null)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -54,8 +54,11 @@ class WakeAlarmSyncApp extends StatelessWidget {
|
||||
routes: <String, WidgetBuilder>{
|
||||
'/': (_) => 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()),
|
||||
},
|
||||
);
|
||||
|
||||
@ -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<void> 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));
|
||||
}
|
||||
|
||||
@ -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/<package>/files/`. Probe-only: `lib/main.dart`
|
||||
/// never constructs it.
|
||||
@ -67,10 +66,4 @@ class FileDrivenScheduler extends AlarmScheduler {
|
||||
@override
|
||||
Future<int> stepsSinceRingStart() async =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<SessionScreen> {
|
||||
double? _lastWeight;
|
||||
double? _confirming;
|
||||
Timer? _timer;
|
||||
int _ticks = 0;
|
||||
bool _finishing = false;
|
||||
String _status = '';
|
||||
|
||||
@ -78,11 +76,22 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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<SessionScreen> {
|
||||
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<SessionScreen> {
|
||||
Future<void> _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<SessionScreen> {
|
||||
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(() {
|
||||
|
||||
@ -148,23 +148,43 @@ class AlarmScheduler {
|
||||
<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
|
||||
/// finish, never from a button — the wake alarm still has no off switch.
|
||||
Future<void> finishSession() => _channel.invokeMethod<void>('finishSession');
|
||||
|
||||
/// Starts sampling the accelerometer; the idle clock starts now.
|
||||
Future<void> beginMotionWatch() =>
|
||||
_channel.invokeMethod<void>('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<Duration?> motionIdle() async {
|
||||
final millis = await _channel.invokeMethod<int>('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<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`,
|
||||
/// `distanceMeters`, `reason`.
|
||||
|
||||
@ -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<String> 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;
|
||||
}
|
||||
|
||||
44
phone_app/lib/services/session_notice.dart
Normal file
44
phone_app/lib/services/session_notice.dart
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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, <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);
|
||||
});
|
||||
}
|
||||
|
||||
81
phone_app/test/services/session_notice_test.dart
Normal file
81
phone_app/test/services/session_notice_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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, <String>['still:bathroomIn']);
|
||||
expect(s.reRingReasons, <String>['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);
|
||||
|
||||
83
phone_app/test/session_screen_relaunch_test.dart
Normal file
83
phone_app/test/session_screen_relaunch_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -14,7 +14,7 @@ void main() {
|
||||
|
||||
late List<MethodCall> 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 = <MethodCall>[];
|
||||
now = DateTime(2026, 9, 17, 8, 4);
|
||||
idleMs = null;
|
||||
ringing = false;
|
||||
published = 0;
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
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, <String, int?>{
|
||||
'atMillis': now.add(MorningSession.legWindow).millisecondsSinceEpoch,
|
||||
expect(arm.arguments, <String, int?>{'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, <String, Object?>{
|
||||
'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<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', (
|
||||
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')));
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user