mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +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 dismiss does not end the morning.** Since 2026-09-16 it hands over, in
|
||||||
the same activity, to the silent **morning session**: weigh in → bathroom tag
|
the same activity, to the silent **morning session**: weigh in → bathroom tag
|
||||||
→ shower → bathroom tag again → wardrobe tag → desk tag. A missed window
|
→ shower → bathroom tag again → wardrobe tag → desk tag. A missed window
|
||||||
(AlarmManager, request code 1003) or a phone still for 4 min re-rings, and
|
(AlarmManager, request code 1003) re-rings; silencing it is the full sequence
|
||||||
silencing that is the full sequence above again; three re-rings fail the
|
again; three re-rings fail the session. Leaving the app is fine: every launch
|
||||||
session. State is persisted after every change and driven by wall-clock
|
while the morning is live lands back on the leg (`SessionNotice.kt`). State is
|
||||||
instants, never Dart timers: **`DOCS-morning-session.md`**.
|
persisted after every change, on wall-clock instants: **`DOCS-morning-session.md`**.
|
||||||
|
|
||||||
**`HomeScreen.initState` must stay strictly sequential** (`_applyPolicy` →
|
**`HomeScreen.initState` must stay strictly sequential** (`_applyPolicy` →
|
||||||
`_loadSyncState` → `_syncToPc`): run concurrently, the startup publish
|
`_loadSyncState` → `_syncToPc`): run concurrently, the startup publish
|
||||||
|
|||||||
@ -42,12 +42,16 @@ value passes the first leg by itself — there is nothing to walk to.
|
|||||||
or rebooted away, and the deadline has to survive all three. `BootReceiver`
|
or rebooted away, and the deadline has to survive all three. `BootReceiver`
|
||||||
restores it; `restoreReRing` drops an instant that has already passed
|
restores it; `restoreReRing` drops an instant that has already passed
|
||||||
(see *Gotchas*).
|
(see *Gotchas*).
|
||||||
- **A still phone** (accelerometer, `MotionMonitor.kt`, no motion for 4 min)
|
- **No still-phone check.** An accelerometer watch (`MotionMonitor.kt`, 4 min
|
||||||
re-rings from Dart via `ringNow`, then routes itself to `/ring` in case the
|
without motion) existed for one day and was deleted on 2026-09-17: it only
|
||||||
full-screen intent lands on the live `singleInstance` activity instead of
|
ran while the session screen was in front, and the real routine leaves the
|
||||||
recreating it. Suspended during the shower leg — the phone is on a shelf.
|
app on purpose (a podcast before the shower), so it was off for most of the
|
||||||
Only works while the session screen is up, which is why `AlarmActivity`
|
session and never rang on the one morning it was watched. The deadlines are
|
||||||
now holds `FLAG_KEEP_SCREEN_ON`; the deadline alarm is the backstop.
|
the only re-ring. Do not bring it back as a foreground service without a
|
||||||
|
morning that shows a lie-in the ≤ 10-minute windows missed.
|
||||||
|
- When Dart rings a deadline itself it routes to `/ring` afterwards, in case
|
||||||
|
the full-screen intent lands on the live `singleInstance` activity instead
|
||||||
|
of recreating it.
|
||||||
- **Silencing a re-ring is the full dismiss sequence**: tag 1, the walk,
|
- **Silencing a re-ring is the full dismiss sequence**: tag 1, the walk,
|
||||||
tag 2, three questions. The ring screen's `_finishDismiss` sees a live
|
tag 2, three questions. The ring screen's `_finishDismiss` sees a live
|
||||||
session for today and resumes it with `reRings + 1`.
|
session for today and resumes it with `reRings + 1`.
|
||||||
@ -64,6 +68,39 @@ Everything is **persisted after every change** (`session_store.dart`,
|
|||||||
`session.current`) and driven by wall-clock instants, because a re-ring
|
`session.current`) and driven by wall-clock instants, because a re-ring
|
||||||
replaces the whole Flutter engine and resumes from the store.
|
replaces the whole Flutter engine and resumes from the store.
|
||||||
|
|
||||||
|
## Leaving the app, and coming back
|
||||||
|
|
||||||
|
The checklist lives in `AlarmActivity` (NFC reader mode and the kept-on
|
||||||
|
screen are asserted there), and the launcher starts `MainActivity`. Until
|
||||||
|
2026-09-17 a home gesture or a recents swipe therefore lost the screen for
|
||||||
|
good: the re-ring still fired, but the app opened on the home screen with no
|
||||||
|
way back to the leg. Now **every launch while the morning is live lands in
|
||||||
|
`AlarmActivity`**, and the home screen is unreachable until the session is
|
||||||
|
over:
|
||||||
|
|
||||||
|
- `MainActivity.resumeMorning()` runs in `onCreate` *and* `onResume` (the
|
||||||
|
activity is `singleTop`; a warm instance never re-runs `onCreate`), before
|
||||||
|
any Dart and before the permission prompt — a permission dialog must never
|
||||||
|
come up over a ring. It starts `AlarmActivity` with the `route` extra and
|
||||||
|
finishes itself.
|
||||||
|
- It decides natively, from `SessionNotice.kt`: a cache Dart rewrites on
|
||||||
|
every leg change (`updateSessionNotice`, always paired with `armReRing` in
|
||||||
|
`session_notice.dart`) holding the session's **date** and the leg label.
|
||||||
|
A cache dated any other day is ignored, never resumed — the phone dying
|
||||||
|
mid-morning must not open a dead checklist the next evening. Flutter's
|
||||||
|
own preferences file is deliberately not parsed from Kotlin.
|
||||||
|
- **A sounding ring wins.** `AlarmService.running` routes the launch — and
|
||||||
|
`AlarmActivity.getInitialRoute()` — to `/ring` whatever the extra says,
|
||||||
|
so a swiped-away ring reopened from the launcher gets the dismiss
|
||||||
|
sequence, not the home screen or a checklist over the noise.
|
||||||
|
- **An ongoing notification** ("Morning session — Tap the bathroom tag",
|
||||||
|
counting down to the re-ring) is one tap back to the leg. Silent channel,
|
||||||
|
`setOnlyAlertOnce`, native chronometer, so Dart posts it per leg, not per
|
||||||
|
tick. `finishSession` is the only thing that cancels it. The launcher
|
||||||
|
redirect is the guarantee; the notification is the convenience.
|
||||||
|
- `main_session_probe.dart` asks `morningLive` before its test ring, so a
|
||||||
|
relaunch of the probe during a session resumes rather than rings.
|
||||||
|
|
||||||
## History and sync
|
## History and sync
|
||||||
|
|
||||||
Finished sessions go to `session.history` (30 kept). The home screen shows
|
Finished sessions go to `session.history` (30 kept). The home screen shows
|
||||||
@ -104,8 +141,8 @@ reinstall gets its history back.
|
|||||||
## Testing it without a bathroom
|
## Testing it without a bathroom
|
||||||
|
|
||||||
`lib/main_session_probe.dart` runs the real app on `FileDrivenScheduler`
|
`lib/main_session_probe.dart` runs the real app on `FileDrivenScheduler`
|
||||||
(`lib/probe/`), which replaces NFC, the step count and the motion watch with
|
(`lib/probe/`), which replaces NFC and the step count with files under
|
||||||
files under `/sdcard/Android/data/com.kuhy.wake_alarm_sync/files/`.
|
`/sdcard/Android/data/com.kuhy.wake_alarm_sync/files/`.
|
||||||
`phone_app/scripts/emu_morning.py` drives it:
|
`phone_app/scripts/emu_morning.py` drives it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@ -113,13 +150,15 @@ flutter run -d emulator-5554 --debug -t lib/main_session_probe.dart > run.log
|
|||||||
scripts/emu_morning.py --log run.log dismiss # ring1, 30 steps, ring2, answers
|
scripts/emu_morning.py --log run.log dismiss # ring1, 30 steps, ring2, answers
|
||||||
scripts/emu_morning.py weight 72.4
|
scripts/emu_morning.py weight 72.4
|
||||||
scripts/emu_morning.py tag bath # then wait 8 min, tag bath again
|
scripts/emu_morning.py tag bath # then wait 8 min, tag bath again
|
||||||
scripts/emu_morning.py idle 250000 # still phone -> re-ring
|
scripts/emu_morning.py home; scripts/emu_morning.py launch # back on the leg
|
||||||
|
scripts/emu_morning.py rering # the deadline broadcast, now
|
||||||
scripts/emu_morning.py --log run.log dismiss # resumes at the same leg, 1/3
|
scripts/emu_morning.py --log run.log dismiss # resumes at the same leg, 1/3
|
||||||
scripts/emu_morning.py tag ward; scripts/emu_morning.py tag desk
|
scripts/emu_morning.py tag ward; scripts/emu_morning.py tag desk
|
||||||
```
|
```
|
||||||
|
|
||||||
Verified this way on 2026-09-16 (API 36 AVD): the whole morning including a
|
Verified this way on 2026-09-16 (API 36 AVD): the whole morning including a
|
||||||
still-phone re-ring and the resume. `flutter build apk --debug --target`
|
re-ring and the resume; on 2026-09-17 the home-and-relaunch round trip on a
|
||||||
|
leg and during a ring. `flutter build apk --debug --target`
|
||||||
produced an APK that ran `main.dart` instead; `flutter run -t` did not.
|
produced an APK that ran `main.dart` instead; `flutter run -t` did not.
|
||||||
The phone remains the only test of the AlarmManager deadline with the
|
The phone remains the only test of the AlarmManager deadline with the
|
||||||
screen off and the app swiped away.
|
screen off and the app swiped away.
|
||||||
|
|||||||
@ -20,7 +20,19 @@ class AlarmActivity : FlutterActivity() {
|
|||||||
/** Dart route the ring screen is registered under in main.dart. */
|
/** Dart route the ring screen is registered under in main.dart. */
|
||||||
private val ringRoute = "/ring"
|
private val ringRoute = "/ring"
|
||||||
|
|
||||||
override fun getInitialRoute(): String = ringRoute
|
/**
|
||||||
|
* The ring by default; the session checklist only when asked for by
|
||||||
|
* [SessionNotice.resumeIntent] (the launcher redirect or the ongoing
|
||||||
|
* notification) AND nothing is sounding. A ring always wins: an intent
|
||||||
|
* that opened the checklist over a live alarm would leave the sound
|
||||||
|
* running with no dismiss sequence in front of it. Read once, at
|
||||||
|
* creation -- a live `singleInstance` instance just comes forward on
|
||||||
|
* whatever screen Dart is showing, which is the right thing.
|
||||||
|
*/
|
||||||
|
override fun getInitialRoute(): String {
|
||||||
|
if (AlarmService.running) return ringRoute
|
||||||
|
return intent?.getStringExtra(SessionNotice.EXTRA_ROUTE) ?: ringRoute
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// showWhenLocked/turnScreenOn are also set as manifest attributes; the
|
// showWhenLocked/turnScreenOn are also set as manifest attributes; the
|
||||||
@ -31,8 +43,8 @@ class AlarmActivity : FlutterActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// The morning session lives in this activity for half an hour after
|
// The morning session lives in this activity for half an hour after
|
||||||
// the ring stops. With the screen allowed to sleep the activity
|
// the ring stops. With the screen allowed to sleep the activity
|
||||||
// pauses, NFC stops polling and the motion watch stops sampling, so
|
// pauses and NFC stops polling, so a tag tap would need a wake and
|
||||||
// the only thing left working would be the AlarmManager backstop.
|
// an unlock first, every leg.
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
dismissKeyguard()
|
dismissKeyguard()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,14 +108,31 @@ object AlarmChannel {
|
|||||||
// no off switch.
|
// no off switch.
|
||||||
"finishSession" -> {
|
"finishSession" -> {
|
||||||
AlarmScheduler.clearReRing(context)
|
AlarmScheduler.clearReRing(context)
|
||||||
MotionMonitor.stop(context)
|
SessionNotice.clear(context)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"beginMotionWatch" -> {
|
// The live leg, for the launcher redirect and the ongoing
|
||||||
MotionMonitor.start(context)
|
// notification; see SessionNotice.kt. Called on every leg
|
||||||
result.success(null)
|
// change, never per tick.
|
||||||
|
"updateSessionNotice" -> {
|
||||||
|
val date = call.argument<String>("date")
|
||||||
|
val label = call.argument<String>("label")
|
||||||
|
if (date == null || label == null) {
|
||||||
|
result.error("bad_args", "date and label are required", null)
|
||||||
|
} else {
|
||||||
|
val at = call.argument<Number>("deadlineMillis")?.toLong()
|
||||||
|
SessionNotice.update(context, date, label, at)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"motionIdleMillis" -> result.success(MotionMonitor.idleMillis())
|
// Whether a launch right now would be redirected into the
|
||||||
|
// morning; the probes ask before starting a test ring.
|
||||||
|
"morningLive" ->
|
||||||
|
result.success(AlarmService.running || SessionNotice.isLiveToday(context))
|
||||||
|
// Whether the ring is sounding; the session screen polls this so
|
||||||
|
// a deadline that rang while it sat in the background puts the
|
||||||
|
// dismiss sequence in front the moment it is looked at.
|
||||||
|
"isRinging" -> result.success(AlarmService.running)
|
||||||
"beginStepCount" -> {
|
"beginStepCount" -> {
|
||||||
StepCounter.markRingStart(context)
|
StepCounter.markRingStart(context)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
|
|||||||
@ -46,6 +46,15 @@ class AlarmService : Service() {
|
|||||||
/** Vibration: 1s buzz, 1s gap, repeating from index 0. */
|
/** Vibration: 1s buzz, 1s gap, repeating from index 0. */
|
||||||
private val VIBRATION_PATTERN = longArrayOf(0, 1000, 1000)
|
private val VIBRATION_PATTERN = longArrayOf(0, 1000, 1000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the ring is sounding right now. Same process as both
|
||||||
|
* activities, so a launch during a ring can route to the dismiss
|
||||||
|
* screen instead of the home screen or a session checklist.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var running: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
/** Starts the ring. Safe to call when already ringing — start is idempotent. */
|
/** Starts the ring. Safe to call when already ringing — start is idempotent. */
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, AlarmService::class.java)
|
val intent = Intent(context, AlarmService::class.java)
|
||||||
@ -54,6 +63,11 @@ class AlarmService : Service() {
|
|||||||
|
|
||||||
/** Stops the ring and releases every resource it holds. */
|
/** Stops the ring and releases every resource it holds. */
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
|
// Cleared here, not only in onDestroy: stopService is
|
||||||
|
// asynchronous, and the session screen that follows a dismiss
|
||||||
|
// polls this flag from its first tick -- a stale true would
|
||||||
|
// re-ring the alarm it had just silenced.
|
||||||
|
running = false
|
||||||
context.stopService(Intent(context, AlarmService::class.java))
|
context.stopService(Intent(context, AlarmService::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,12 +86,14 @@ class AlarmService : Service() {
|
|||||||
acquireWakeLock()
|
acquireWakeLock()
|
||||||
startSound()
|
startSound()
|
||||||
startVibration()
|
startVibration()
|
||||||
|
running = true
|
||||||
// START_STICKY: if the system kills us under memory pressure while the
|
// START_STICKY: if the system kills us under memory pressure while the
|
||||||
// user is still asleep, the alarm comes back rather than going silent.
|
// user is still asleep, the alarm comes back rather than going silent.
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
running = false
|
||||||
stopSound()
|
stopSound()
|
||||||
stopVibration()
|
stopVibration()
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
|
|||||||
@ -20,9 +20,28 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
if (resumeMorning()) return
|
||||||
requestAlarmPermissions()
|
requestAlarmPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps aside into [AlarmActivity] while the morning is live: a ring is
|
||||||
|
* sounding, or today's session is still on a leg.
|
||||||
|
*
|
||||||
|
* Decided here, natively, before any Dart runs: the home screen must
|
||||||
|
* never come up mid-session (a home gesture used to be a way out with
|
||||||
|
* no way back, 2026-09-17), and the permission prompt above must never
|
||||||
|
* appear over a ring. Checked on resume as well as create -- this
|
||||||
|
* activity is `singleTop`, and a warm instance brought back from the
|
||||||
|
* launcher never re-runs onCreate.
|
||||||
|
*/
|
||||||
|
private fun resumeMorning(): Boolean {
|
||||||
|
if (!AlarmService.running && !SessionNotice.isLiveToday(this)) return false
|
||||||
|
startActivity(SessionNotice.resumeIntent(this))
|
||||||
|
finishAndRemoveTask()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks for the two runtime permissions the alarm cannot work without.
|
* Asks for the two runtime permissions the alarm cannot work without.
|
||||||
*
|
*
|
||||||
@ -114,6 +133,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
if (resumeMorning()) return
|
||||||
if (registeringTag) alarmChannel?.let { NfcReader.start(this, it) }
|
if (registeringTag) alarmChannel?.let { NfcReader.start(this, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>{
|
routes: <String, WidgetBuilder>{
|
||||||
'/': (_) => const HomeScreen(),
|
'/': (_) => const HomeScreen(),
|
||||||
kRingRoute: (_) => RingScreen(scheduler: newScheduler?.call()),
|
kRingRoute: (_) => RingScreen(scheduler: newScheduler?.call()),
|
||||||
// Reached only by pushReplacement from the ring screen, inside
|
// Reached by pushReplacement from the ring screen, or as
|
||||||
// AlarmActivity; it is never an initial route.
|
// AlarmActivity's initial route when the launcher or the ongoing
|
||||||
|
// notification reopens a live session (SessionNotice.kt). Always
|
||||||
|
// inside AlarmActivity: NFC reader mode and the kept-on screen live
|
||||||
|
// there.
|
||||||
kSessionRoute: (_) => SessionScreen(scheduler: newScheduler?.call()),
|
kSessionRoute: (_) => SessionScreen(scheduler: newScheduler?.call()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,9 +8,11 @@ import 'package:wake_alarm_sync/services/session_tag_settings.dart';
|
|||||||
|
|
||||||
/// Emulator entrypoint: the whole morning, driven from adb.
|
/// Emulator entrypoint: the whole morning, driven from adb.
|
||||||
///
|
///
|
||||||
/// Like `main_ring_probe.dart` it rings on launch, but it runs the app on a
|
/// Like `main_ring_probe.dart` it rings on launch -- unless the morning is
|
||||||
/// [FileDrivenScheduler] and registers fixed tag ids, so the dismiss walk
|
/// already live, in which case the launch is redirected into it exactly as
|
||||||
/// and the session legs can be fed by writing files:
|
/// the launcher's would be -- but it runs the app on a [FileDrivenScheduler]
|
||||||
|
/// and registers fixed tag ids, so the dismiss walk and the session legs can
|
||||||
|
/// be fed by writing files:
|
||||||
///
|
///
|
||||||
/// ```sh
|
/// ```sh
|
||||||
/// F=/sdcard/Android/data/com.kuhy.wake_alarm_sync/files
|
/// F=/sdcard/Android/data/com.kuhy.wake_alarm_sync/files
|
||||||
@ -27,6 +29,7 @@ Future<void> main() async {
|
|||||||
await SessionTagSettings.saveTagAt(SessionTagSlot.bathroom, 'bath');
|
await SessionTagSettings.saveTagAt(SessionTagSlot.bathroom, 'bath');
|
||||||
await SessionTagSettings.saveTagAt(SessionTagSlot.wardrobe, 'ward');
|
await SessionTagSettings.saveTagAt(SessionTagSlot.wardrobe, 'ward');
|
||||||
await SessionTagSettings.saveTagAt(SessionTagSlot.desk, 'desk');
|
await SessionTagSettings.saveTagAt(SessionTagSlot.desk, 'desk');
|
||||||
await AlarmScheduler().ringNow();
|
final scheduler = AlarmScheduler();
|
||||||
|
if (!await scheduler.morningLive()) await scheduler.ringNow();
|
||||||
runApp(const WakeAlarmSyncApp(newScheduler: FileDrivenScheduler.new));
|
runApp(const WakeAlarmSyncApp(newScheduler: FileDrivenScheduler.new));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,10 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
|||||||
/// The emulator has no NFC and no step sensor, so the real dismiss sequence
|
/// The emulator has no NFC and no step sensor, so the real dismiss sequence
|
||||||
/// and the whole morning session are unreachable on it. This bridge keeps
|
/// and the whole morning session are unreachable on it. This bridge keeps
|
||||||
/// every alarm, ring and re-ring call on the real channel and replaces only
|
/// every alarm, ring and re-ring call on the real channel and replaces only
|
||||||
/// the three inputs hardware would supply:
|
/// the two inputs hardware would supply:
|
||||||
///
|
///
|
||||||
/// - `files/tag` — a tag id; consumed once, delivered to the NFC listener
|
/// - `files/tag` — a tag id; consumed once, delivered to the NFC listener
|
||||||
/// - `files/steps` — the step count the ring's walk stage reads
|
/// - `files/steps` — the step count the ring's walk stage reads
|
||||||
/// - `files/idle_ms` — how long the phone has "lain still"
|
|
||||||
///
|
///
|
||||||
/// under `/sdcard/Android/data/<package>/files/`. Probe-only: `lib/main.dart`
|
/// under `/sdcard/Android/data/<package>/files/`. Probe-only: `lib/main.dart`
|
||||||
/// never constructs it.
|
/// never constructs it.
|
||||||
@ -67,10 +66,4 @@ class FileDrivenScheduler extends AlarmScheduler {
|
|||||||
@override
|
@override
|
||||||
Future<int> stepsSinceRingStart() async =>
|
Future<int> stepsSinceRingStart() async =>
|
||||||
int.tryParse(await _read('steps') ?? '') ?? -1;
|
int.tryParse(await _read('steps') ?? '') ?? -1;
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Duration?> motionIdle() async {
|
|
||||||
final ms = int.tryParse(await _read('idle_ms') ?? '');
|
|
||||||
return ms == null ? null : Duration(milliseconds: ms);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,8 +52,8 @@ class SessionBody extends StatelessWidget {
|
|||||||
Text('Stay up', style: theme.textTheme.headlineMedium),
|
Text('Stay up', style: theme.textTheme.headlineMedium),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'The alarm is off. It comes back if a step below is missed, '
|
'The alarm is off. It comes back if a step below is missed. '
|
||||||
'or if the phone lies still.',
|
'Leaving the app is fine — the notification brings you back.',
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import 'package:wake_alarm_sync/main.dart';
|
|||||||
import 'package:wake_alarm_sync/screens/session_body.dart';
|
import 'package:wake_alarm_sync/screens/session_body.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||||
import 'package:wake_alarm_sync/services/morning_session.dart';
|
import 'package:wake_alarm_sync/services/morning_session.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/session_notice.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_publisher.dart';
|
import 'package:wake_alarm_sync/services/session_publisher.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_rules.dart';
|
import 'package:wake_alarm_sync/services/session_rules.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_store.dart';
|
import 'package:wake_alarm_sync/services/session_store.dart';
|
||||||
@ -14,9 +15,6 @@ import 'package:wake_alarm_sync/services/weight_entry.dart';
|
|||||||
/// How often the clocks advance and the countdown redraws.
|
/// How often the clocks advance and the countdown redraws.
|
||||||
const Duration kSessionTick = Duration(seconds: 1);
|
const Duration kSessionTick = Duration(seconds: 1);
|
||||||
|
|
||||||
/// Every this many ticks the motion watch is read.
|
|
||||||
const int kMotionPollEvery = 5;
|
|
||||||
|
|
||||||
/// Logs to logcat in release too: `developer.log` is invisible there, and a
|
/// Logs to logcat in release too: `developer.log` is invisible there, and a
|
||||||
/// morning that went wrong on the phone is only ever debugged from logcat.
|
/// morning that went wrong on the phone is only ever debugged from logcat.
|
||||||
void sessionLog(String message) => debugPrint('WakeAlarmSession: $message');
|
void sessionLog(String message) => debugPrint('WakeAlarmSession: $message');
|
||||||
@ -25,9 +23,10 @@ void sessionLog(String message) => debugPrint('WakeAlarmSession: $message');
|
|||||||
///
|
///
|
||||||
/// Shown in `AlarmActivity` in place of the ring screen, so NFC reader mode
|
/// Shown in `AlarmActivity` in place of the ring screen, so NFC reader mode
|
||||||
/// and the kept-on screen carry over. Nothing here rings by itself; the
|
/// and the kept-on screen carry over. Nothing here rings by itself; the
|
||||||
/// deadlines are AlarmManager alarms armed through [AlarmScheduler.armReRing]
|
/// deadlines are AlarmManager alarms armed through [pointNativeAtLeg] and
|
||||||
/// and this widget merely keeps them pointed at the live leg. What it does
|
/// this widget merely keeps them, and the ongoing notification, pointed at
|
||||||
/// decide is the still-phone ring, which needs a live sensor.
|
/// the live leg. Leaving the app is allowed (a podcast before the shower):
|
||||||
|
/// the launcher and the notification both come back here.
|
||||||
class SessionScreen extends StatefulWidget {
|
class SessionScreen extends StatefulWidget {
|
||||||
/// Creates the screen; every dependency is injectable for tests.
|
/// Creates the screen; every dependency is injectable for tests.
|
||||||
const SessionScreen({
|
const SessionScreen({
|
||||||
@ -67,7 +66,6 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
double? _lastWeight;
|
double? _lastWeight;
|
||||||
double? _confirming;
|
double? _confirming;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _ticks = 0;
|
|
||||||
bool _finishing = false;
|
bool _finishing = false;
|
||||||
String _status = '';
|
String _status = '';
|
||||||
|
|
||||||
@ -78,11 +76,22 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the session the ring screen left, or starts one if reached cold.
|
/// Loads the session the ring screen left, or starts one if reached cold.
|
||||||
|
///
|
||||||
|
/// Cold with today's session already in history is a stale native record
|
||||||
|
/// (`SessionNotice.kt`) that redirected a launch here: one session per
|
||||||
|
/// day, so clear it and close rather than start the morning over.
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
final tags = await SessionTagSettings.load();
|
final tags = await SessionTagSettings.load();
|
||||||
final now = _now();
|
final now = _now();
|
||||||
var session = await _store.loadCurrent();
|
var session = await _store.loadCurrent();
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
|
final today = SessionRules.dateKey(now);
|
||||||
|
if ((await _store.history()).any((s) => s.date == today)) {
|
||||||
|
sessionLog('already finished today; closing');
|
||||||
|
await _scheduler.finishSession();
|
||||||
|
await _scheduler.closeRingScreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
session = SessionRules.start(
|
session = SessionRules.start(
|
||||||
now: now,
|
now: now,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
@ -93,8 +102,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
await _store.save(session);
|
await _store.save(session);
|
||||||
}
|
}
|
||||||
final last = await _store.lastWeightKg();
|
final last = await _store.lastWeightKg();
|
||||||
await _scheduler.beginMotionWatch();
|
await pointNativeAtLeg(_scheduler, session);
|
||||||
await _scheduler.armReRing(session.nextDeadline());
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_tags = tags;
|
_tags = tags;
|
||||||
@ -143,7 +151,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
Future<void> _commit(String status) async {
|
Future<void> _commit(String status) async {
|
||||||
final session = _session!;
|
final session = _session!;
|
||||||
await _store.save(session);
|
await _store.save(session);
|
||||||
await _scheduler.armReRing(session.nextDeadline());
|
await pointNativeAtLeg(_scheduler, session);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _status = status);
|
setState(() => _status = status);
|
||||||
if (!session.isActive) await _finish();
|
if (!session.isActive) await _finish();
|
||||||
@ -157,19 +165,17 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
if (tick.changed) {
|
if (tick.changed) {
|
||||||
// A bypass passes a leg, so the deadline moves with it.
|
// A bypass passes a leg, so the deadline moves with it.
|
||||||
await _store.save(session);
|
await _store.save(session);
|
||||||
await _scheduler.armReRing(session.nextDeadline());
|
await pointNativeAtLeg(_scheduler, session);
|
||||||
}
|
}
|
||||||
if (tick.ringReason != null) {
|
if (tick.ringReason != null) {
|
||||||
await _ring(tick.ringReason!, now);
|
await _ring(tick.ringReason!, now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_ticks++;
|
// The native deadline already rang (see [AlarmScheduler.isRinging]):
|
||||||
if (_ticks % kMotionPollEvery == 0) {
|
// the dismiss sequence belongs in front now, not after the 20 s grace.
|
||||||
final idle = await _scheduler.motionIdle();
|
if (session.isActive && await _scheduler.isRinging()) {
|
||||||
if (session.stillnessRingDue(idle)) {
|
await _ring('deadline:${session.currentLeg!.name}', now);
|
||||||
await _ring('still:${session.currentLeg!.name}', now);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@ -148,23 +148,43 @@ class AlarmScheduler {
|
|||||||
<String, int?>{'atMillis': at?.millisecondsSinceEpoch},
|
<String, int?>{'atMillis': at?.millisecondsSinceEpoch},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Ends the session: drops the pending re-ring and the motion watch.
|
/// Ends the session: drops the pending re-ring and the session notice.
|
||||||
///
|
///
|
||||||
/// The only path that clears a re-ring. Called from the session's own
|
/// The only path that clears a re-ring. Called from the session's own
|
||||||
/// finish, never from a button — the wake alarm still has no off switch.
|
/// finish, never from a button — the wake alarm still has no off switch.
|
||||||
Future<void> finishSession() => _channel.invokeMethod<void>('finishSession');
|
Future<void> finishSession() => _channel.invokeMethod<void>('finishSession');
|
||||||
|
|
||||||
/// Starts sampling the accelerometer; the idle clock starts now.
|
/// Hands the native side the live leg: [date] (`yyyy-mm-dd`), its [label]
|
||||||
Future<void> beginMotionWatch() =>
|
/// and the instant it re-rings at, or null when it has no deadline.
|
||||||
_channel.invokeMethod<void>('beginMotionWatch');
|
|
||||||
|
|
||||||
/// How long the phone has lain still, or null when nothing is watching.
|
|
||||||
///
|
///
|
||||||
/// Null is "unknown", never "still": a missing sensor must not re-ring.
|
/// This is what the launcher redirect and the ongoing notification read
|
||||||
Future<Duration?> motionIdle() async {
|
/// (`SessionNotice.kt`), so it must follow every leg change; the
|
||||||
final millis = await _channel.invokeMethod<int>('motionIdleMillis') ?? -1;
|
/// notification counts down by itself, so it need not follow the tick.
|
||||||
return millis < 0 ? null : Duration(milliseconds: millis);
|
Future<void> updateSessionNotice({
|
||||||
}
|
required String date,
|
||||||
|
required String label,
|
||||||
|
DateTime? deadline,
|
||||||
|
}) => _channel.invokeMethod<void>('updateSessionNotice', <String, Object?>{
|
||||||
|
'date': date,
|
||||||
|
'label': label,
|
||||||
|
'deadlineMillis': deadline?.millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether a launch right now is redirected into the morning: a ring is
|
||||||
|
/// sounding, or today's session is still on a leg. The probes ask before
|
||||||
|
/// starting a test ring on top of it.
|
||||||
|
Future<bool> morningLive() async =>
|
||||||
|
await _channel.invokeMethod<bool>('morningLive') ?? false;
|
||||||
|
|
||||||
|
/// Whether the ring is sounding right now.
|
||||||
|
///
|
||||||
|
/// The session screen polls this every tick. A deadline that fires while
|
||||||
|
/// the checklist sits in the background is a heads-up notification plus
|
||||||
|
/// sound (an unlocked, in-use phone downgrades the full-screen intent) and
|
||||||
|
/// the live `singleInstance` activity is not recreated, so nothing else
|
||||||
|
/// would put the dismiss sequence in front of the noise.
|
||||||
|
Future<bool> isRinging() async =>
|
||||||
|
await _channel.invokeMethod<bool>('isRinging') ?? false;
|
||||||
|
|
||||||
/// Raw presence observations: `ssid`, `bssid`, `lat`, `lon`, `accuracy`,
|
/// Raw presence observations: `ssid`, `bssid`, `lat`, `lon`, `accuracy`,
|
||||||
/// `distanceMeters`, `reason`.
|
/// `distanceMeters`, `reason`.
|
||||||
|
|||||||
@ -108,9 +108,6 @@ class MorningSession {
|
|||||||
/// your feet — never a silent skip.
|
/// your feet — never a silent skip.
|
||||||
static const Duration bypassGrace = Duration(minutes: 5);
|
static const Duration bypassGrace = Duration(minutes: 5);
|
||||||
|
|
||||||
/// A phone that has not moved for this long is on a nightstand.
|
|
||||||
static const Duration stillnessLimit = Duration(minutes: 4);
|
|
||||||
|
|
||||||
/// Re-rings per morning before the session gives up.
|
/// Re-rings per morning before the session gives up.
|
||||||
static const int maxReRings = 3;
|
static const int maxReRings = 3;
|
||||||
|
|
||||||
@ -147,7 +144,7 @@ class MorningSession {
|
|||||||
/// Re-rings dismissed so far this session.
|
/// Re-rings dismissed so far this session.
|
||||||
int reRings;
|
int reRings;
|
||||||
|
|
||||||
/// Why each re-ring happened, e.g. `still:dressed`, `deadline:atDesk`.
|
/// Why each re-ring happened, e.g. `deadline:atDesk`.
|
||||||
final List<String> reRingReasons;
|
final List<String> reRingReasons;
|
||||||
|
|
||||||
/// Set by the driver before it rings, so the dismiss can name the reason.
|
/// Set by the driver before it rings, so the dismiss can name the reason.
|
||||||
@ -188,9 +185,6 @@ class MorningSession {
|
|||||||
/// Whether another re-ring is allowed.
|
/// Whether another re-ring is allowed.
|
||||||
bool get canReRing => reRings < maxReRings;
|
bool get canReRing => reRings < maxReRings;
|
||||||
|
|
||||||
/// Whether the still-phone check is off: it lies on a shelf in the shower.
|
|
||||||
bool get stillnessSuspended => currentLeg == SessionLeg.showerDone;
|
|
||||||
|
|
||||||
/// Whether this session still needs driving.
|
/// Whether this session still needs driving.
|
||||||
bool get isActive => outcome == SessionOutcome.active;
|
bool get isActive => outcome == SessionOutcome.active;
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
/// Why to ring right now, or null.
|
||||||
///
|
///
|
||||||
/// Set only for a stillness ring or a deadline the native alarm should
|
/// Set only for a deadline the native alarm should already have fired —
|
||||||
/// already have fired — calling the ring twice is harmless, missing it is
|
/// calling the ring twice is harmless, missing it is not.
|
||||||
/// not.
|
|
||||||
final String? ringReason;
|
final String? ringReason;
|
||||||
|
|
||||||
/// Something to show, or null.
|
/// Something to show, or null.
|
||||||
@ -141,14 +140,6 @@ extension SessionRules on MorningSession {
|
|||||||
return SessionTick(ringReason: 'deadline:${leg.name}');
|
return SessionTick(ringReason: 'deadline:${leg.name}');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the phone has lain still long enough to ring.
|
|
||||||
bool stillnessRingDue(Duration? idle) =>
|
|
||||||
isActive &&
|
|
||||||
canReRing &&
|
|
||||||
!stillnessSuspended &&
|
|
||||||
idle != null &&
|
|
||||||
idle >= MorningSession.stillnessLimit;
|
|
||||||
|
|
||||||
/// Names the re-ring the driver is about to trigger.
|
/// Names the re-ring the driver is about to trigger.
|
||||||
void markReRing(String reason, DateTime now) {
|
void markReRing(String reason, DateTime now) {
|
||||||
pendingReRingReason = reason;
|
pendingReRingReason = reason;
|
||||||
|
|||||||
@ -8,8 +8,10 @@ and types the challenge answers it reads back from logcat. Everything else
|
|||||||
|
|
||||||
scripts/emu_morning.py dismiss # tag 1, walk, tag 2, 3 answers
|
scripts/emu_morning.py dismiss # tag 1, walk, tag 2, 3 answers
|
||||||
scripts/emu_morning.py tag bath # one session tag
|
scripts/emu_morning.py tag bath # one session tag
|
||||||
scripts/emu_morning.py idle 300000 # the phone "lay still" 5 min
|
scripts/emu_morning.py rering # fire the deadline alarm now
|
||||||
scripts/emu_morning.py weight 72.4 # type a weight and submit
|
scripts/emu_morning.py weight 72.4 # type a weight and submit
|
||||||
|
scripts/emu_morning.py home # leave the app (home gesture)
|
||||||
|
scripts/emu_morning.py launch # tap the launcher icon
|
||||||
scripts/emu_morning.py shot out.png # screenshot
|
scripts/emu_morning.py shot out.png # screenshot
|
||||||
|
|
||||||
Reads answers from a ``flutter run`` log (``--log``) or from ``adb logcat``.
|
Reads answers from a ``flutter run`` log (``--log``) or from ``adb logcat``.
|
||||||
@ -63,6 +65,42 @@ def write_file(serial: str, name: str, content: str) -> None:
|
|||||||
adb(serial, "shell", f"mkdir -p {FILES} && printf %s '{content}' > {FILES}/{name}")
|
adb(serial, "shell", f"mkdir -p {FILES} && printf %s '{content}' > {FILES}/{name}")
|
||||||
|
|
||||||
|
|
||||||
|
def rering(serial: str) -> None:
|
||||||
|
"""Delivers the session-deadline broadcast, as AlarmManager would.
|
||||||
|
|
||||||
|
Targets the receiver by component, so there is no waiting for a real
|
||||||
|
ten-minute window. The receiver is not exported, so the shell user's
|
||||||
|
broadcast is silently dropped (``result=0`` and nothing happens); adbd
|
||||||
|
has to run as root, which a Google-APIs AVD allows and a Play one does
|
||||||
|
not. Idempotent, so it is done here rather than left to the caller.
|
||||||
|
"""
|
||||||
|
adb(serial, "root")
|
||||||
|
adb(serial, "wait-for-device")
|
||||||
|
adb(
|
||||||
|
serial,
|
||||||
|
"shell",
|
||||||
|
"am broadcast -n",
|
||||||
|
f"{PACKAGE}/.AlarmReceiver",
|
||||||
|
"--es reason rering",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def home(serial: str) -> None:
|
||||||
|
"""Backgrounds whatever is in front, as the home gesture does."""
|
||||||
|
adb(serial, "shell", "input keyevent HOME")
|
||||||
|
|
||||||
|
|
||||||
|
def launch(serial: str) -> None:
|
||||||
|
"""Starts the app from its launcher entry, as a tap on the icon does."""
|
||||||
|
adb(
|
||||||
|
serial,
|
||||||
|
"shell",
|
||||||
|
"monkey -p",
|
||||||
|
PACKAGE,
|
||||||
|
"-c android.intent.category.LAUNCHER 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def screenshot(serial: str, path: Path) -> None:
|
def screenshot(serial: str, path: Path) -> None:
|
||||||
"""Captures the screen as PNG to [path]."""
|
"""Captures the screen as PNG to [path]."""
|
||||||
path.write_bytes(adb(serial, "exec-out", "screencap", "-p"))
|
path.write_bytes(adb(serial, "exec-out", "screencap", "-p"))
|
||||||
@ -172,7 +210,9 @@ def main(argv: list[str]) -> int:
|
|||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
sub.add_parser("dismiss")
|
sub.add_parser("dismiss")
|
||||||
sub.add_parser("tag").add_argument("id")
|
sub.add_parser("tag").add_argument("id")
|
||||||
sub.add_parser("idle").add_argument("ms")
|
sub.add_parser("rering")
|
||||||
|
sub.add_parser("home")
|
||||||
|
sub.add_parser("launch")
|
||||||
sub.add_parser("weight").add_argument("kg")
|
sub.add_parser("weight").add_argument("kg")
|
||||||
sub.add_parser("shot").add_argument("path", type=Path)
|
sub.add_parser("shot").add_argument("path", type=Path)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
@ -180,8 +220,12 @@ def main(argv: list[str]) -> int:
|
|||||||
dismiss(args.serial, args.log)
|
dismiss(args.serial, args.log)
|
||||||
elif args.cmd == "tag":
|
elif args.cmd == "tag":
|
||||||
write_file(args.serial, "tag", args.id)
|
write_file(args.serial, "tag", args.id)
|
||||||
elif args.cmd == "idle":
|
elif args.cmd == "rering":
|
||||||
write_file(args.serial, "idle_ms", args.ms)
|
rering(args.serial)
|
||||||
|
elif args.cmd == "home":
|
||||||
|
home(args.serial)
|
||||||
|
elif args.cmd == "launch":
|
||||||
|
launch(args.serial)
|
||||||
elif args.cmd == "weight":
|
elif args.cmd == "weight":
|
||||||
type_and_submit(args.serial, args.kg)
|
type_and_submit(args.serial, args.kg)
|
||||||
elif args.cmd == "shot":
|
elif args.cmd == "shot":
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart';
|
|||||||
/// Half of the cross-language gate (`scripts/free_days_interop/check.sh`).
|
/// Half of the cross-language gate (`scripts/free_days_interop/check.sh`).
|
||||||
///
|
///
|
||||||
/// Writes a pool through the Dart rules to `FREE_DAYS_INTEROP_OUT`, then the
|
/// Writes a pool through the Dart rules to `FREE_DAYS_INTEROP_OUT`, then the
|
||||||
/// script replays the same edits through the Python package and compares. Under a plain
|
/// script replays the same edits through the Python package and compares.
|
||||||
/// `flutter test` the variable is unset and this does nothing: the point is
|
/// Under a plain `flutter test` the variable is unset and this does nothing:
|
||||||
/// the comparison, and only the script can make it.
|
/// the point is the comparison, and only the script can make it.
|
||||||
class _FilePersistence implements LogPersistence {
|
class _FilePersistence implements LogPersistence {
|
||||||
_FilePersistence(this.file);
|
_FilePersistence(this.file);
|
||||||
|
|
||||||
|
|||||||
@ -80,4 +80,35 @@ void main() {
|
|||||||
test('stepsSinceRingStart reports -1 when the platform gives null', () async {
|
test('stepsSinceRingStart reports -1 when the platform gives null', () async {
|
||||||
expect(await scheduler.stepsSinceRingStart(), -1);
|
expect(await scheduler.stepsSinceRingStart(), -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateSessionNotice carries date, label and the deadline', () async {
|
||||||
|
final at = DateTime(2026, 9, 17, 8, 45);
|
||||||
|
await scheduler.updateSessionNotice(
|
||||||
|
date: '2026-09-17',
|
||||||
|
label: 'Tap the bathroom tag',
|
||||||
|
deadline: at,
|
||||||
|
);
|
||||||
|
expect(calls.single.method, 'updateSessionNotice');
|
||||||
|
expect(calls.single.arguments, <String, Object?>{
|
||||||
|
'date': '2026-09-17',
|
||||||
|
'label': 'Tap the bathroom tag',
|
||||||
|
'deadlineMillis': at.millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSessionNotice sends null for a leg with no deadline', () async {
|
||||||
|
await scheduler.updateSessionNotice(date: '2026-09-17', label: 'x');
|
||||||
|
expect(
|
||||||
|
(calls.single.arguments as Map<Object?, Object?>)['deadlineMillis'],
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('morningLive and isRinging fail closed on a null result', () async {
|
||||||
|
expect(await scheduler.morningLive(), isFalse);
|
||||||
|
expect(await scheduler.isRinging(), isFalse);
|
||||||
|
reply = true;
|
||||||
|
expect(await scheduler.morningLive(), isTrue);
|
||||||
|
expect(await scheduler.isRinging(), isTrue);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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),
|
s.deadline(SessionLeg.bathroomIn),
|
||||||
t0.add(const Duration(minutes: 10)),
|
t0.add(const Duration(minutes: 10)),
|
||||||
);
|
);
|
||||||
expect(s.stillnessSuspended, isTrue);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -178,30 +177,17 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'a stillness ring keeps its reason and does not spend the deadline',
|
'a ring before the deadline keeps its reason and does not spend it',
|
||||||
() {
|
() {
|
||||||
final s = fresh()
|
final s = fresh()
|
||||||
..markReRing('still:bathroomIn', t0)
|
..markReRing('deadline:bathroomIn', t0)
|
||||||
..recordReRing(t0.add(const Duration(minutes: 3)));
|
..recordReRing(t0.add(const Duration(minutes: 3)));
|
||||||
expect(s.reRingReasons, <String>['still:bathroomIn']);
|
expect(s.reRingReasons, <String>['deadline:bathroomIn']);
|
||||||
expect(s.deadlineRung, isEmpty);
|
expect(s.deadlineRung, isEmpty);
|
||||||
expect(s.nextDeadline(), isNotNull);
|
expect(s.nextDeadline(), isNotNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('stillness is due only when live, allowed and not showering', () {
|
|
||||||
final s = fresh();
|
|
||||||
expect(s.stillnessRingDue(const Duration(minutes: 4)), isTrue);
|
|
||||||
expect(s.stillnessRingDue(const Duration(minutes: 3)), isFalse);
|
|
||||||
expect(s.stillnessRingDue(null), isFalse);
|
|
||||||
expect(
|
|
||||||
inBathroom().stillnessRingDue(const Duration(minutes: 9)),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
s.reRings = MorningSession.maxReRings;
|
|
||||||
expect(s.stillnessRingDue(const Duration(minutes: 9)), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a miss with no re-rings left fails the session', () {
|
test('a miss with no re-rings left fails the session', () {
|
||||||
final s = fresh()..reRings = MorningSession.maxReRings;
|
final s = fresh()..reRings = MorningSession.maxReRings;
|
||||||
expect(s.nextDeadline(), isNull);
|
expect(s.nextDeadline(), isNull);
|
||||||
|
|||||||
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 List<MethodCall> calls;
|
||||||
late DateTime now;
|
late DateTime now;
|
||||||
int? idleMs;
|
var ringing = false;
|
||||||
var published = 0;
|
var published = 0;
|
||||||
|
|
||||||
void mockChannel() {
|
void mockChannel() {
|
||||||
@ -24,7 +24,7 @@ void main() {
|
|||||||
(call) async {
|
(call) async {
|
||||||
calls.add(call);
|
calls.add(call);
|
||||||
if (call.method == 'hasNfc') return true;
|
if (call.method == 'hasNfc') return true;
|
||||||
if (call.method == 'motionIdleMillis') return idleMs ?? -1;
|
if (call.method == 'isRinging') return ringing;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -53,7 +53,7 @@ void main() {
|
|||||||
setUp(() async {
|
setUp(() async {
|
||||||
calls = <MethodCall>[];
|
calls = <MethodCall>[];
|
||||||
now = DateTime(2026, 9, 17, 8, 4);
|
now = DateTime(2026, 9, 17, 8, 4);
|
||||||
idleMs = null;
|
ringing = false;
|
||||||
published = 0;
|
published = 0;
|
||||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
mockChannel();
|
mockChannel();
|
||||||
@ -92,14 +92,33 @@ void main() {
|
|||||||
expect(find.text('Stay up'), findsOneWidget);
|
expect(find.text('Stay up'), findsOneWidget);
|
||||||
expect(find.text('Weigh in'), findsOneWidget);
|
expect(find.text('Weigh in'), findsOneWidget);
|
||||||
expect(find.text('Alarm returns in 10:00'), findsOneWidget);
|
expect(find.text('Alarm returns in 10:00'), findsOneWidget);
|
||||||
expect(methods(), contains('beginMotionWatch'));
|
final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch;
|
||||||
final arm = calls.firstWhere((c) => c.method == 'armReRing');
|
final arm = calls.firstWhere((c) => c.method == 'armReRing');
|
||||||
expect(arm.arguments, <String, int?>{
|
expect(arm.arguments, <String, int?>{'atMillis': deadline});
|
||||||
'atMillis': now.add(MorningSession.legWindow).millisecondsSinceEpoch,
|
// The native record behind the launcher redirect and the notification
|
||||||
|
// follows the deadline in the same breath.
|
||||||
|
final notice = calls.firstWhere((c) => c.method == 'updateSessionNotice');
|
||||||
|
expect(notice.arguments, <String, Object?>{
|
||||||
|
'date': '2026-09-17',
|
||||||
|
'label': 'Tap the bathroom tag',
|
||||||
|
'deadlineMillis': deadline,
|
||||||
});
|
});
|
||||||
expect((await SessionStore().loadCurrent())?.date, '2026-09-17');
|
expect((await SessionStore().loadCurrent())?.date, '2026-09-17');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('every leg change re-points the native notice', (tester) async {
|
||||||
|
await pumpSession(tester);
|
||||||
|
await logWeight(tester, '72');
|
||||||
|
await tap('bath');
|
||||||
|
await tester.pump();
|
||||||
|
final labels = calls
|
||||||
|
.where((c) => c.method == 'updateSessionNotice')
|
||||||
|
.map((c) => (c.arguments as Map<Object?, Object?>)['label'])
|
||||||
|
.toList();
|
||||||
|
expect(labels.last, 'Shower, then tap the bathroom tag again');
|
||||||
|
expect(methods(), isNot(contains('beginMotionWatch')));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('walks every leg, then finishes, archives and closes', (
|
testWidgets('walks every leg, then finishes, archives and closes', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@ -137,7 +156,7 @@ void main() {
|
|||||||
expect(done.reRings, 0);
|
expect(done.reRings, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('a still phone rings and routes to the ring screen', (
|
testWidgets('a native ring already sounding routes to the ring screen', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await pumpSession(tester);
|
await pumpSession(tester);
|
||||||
@ -146,22 +165,22 @@ void main() {
|
|||||||
now = now.add(const Duration(minutes: 9));
|
now = now.add(const Duration(minutes: 9));
|
||||||
await tap('bath');
|
await tap('bath');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
idleMs = MorningSession.stillnessLimit.inMilliseconds;
|
ringing = true;
|
||||||
await tick(tester, kMotionPollEvery);
|
await tick(tester);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(methods(), contains('ringNow'));
|
expect(methods(), contains('ringNow'));
|
||||||
expect(find.text('RING'), findsOneWidget);
|
expect(find.text('RING'), findsOneWidget);
|
||||||
final saved = (await SessionStore().loadCurrent())!;
|
final saved = (await SessionStore().loadCurrent())!;
|
||||||
expect(saved.pendingReRingReason, 'still:dressed');
|
expect(saved.pendingReRingReason, 'deadline:dressed');
|
||||||
expect(saved.currentLeg, SessionLeg.dressed);
|
expect(saved.currentLeg, SessionLeg.dressed);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('stillness is ignored while showering', (tester) async {
|
testWidgets('a silent phone keeps ticking without ringing', (tester) async {
|
||||||
await pumpSession(tester);
|
await pumpSession(tester);
|
||||||
await logWeight(tester, '72');
|
await logWeight(tester, '72');
|
||||||
await tap('bath');
|
await tap('bath');
|
||||||
idleMs = MorningSession.stillnessLimit.inMilliseconds * 2;
|
await tick(tester, 5);
|
||||||
await tick(tester, kMotionPollEvery);
|
expect(methods(), contains('isRinging'));
|
||||||
expect(methods(), isNot(contains('ringNow')));
|
expect(methods(), isNot(contains('ringNow')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user