diff --git a/DOCS-morning-session.md b/DOCS-morning-session.md index 578330b..7da13d2 100644 --- a/DOCS-morning-session.md +++ b/DOCS-morning-session.md @@ -25,6 +25,10 @@ weigh in -> bathroom tag (≤ 2 min later) -> shower -> bathroom tag again | `dressed` | wardrobe tag | previous + 10 min | | | `atDesk` | desk tag | previous + 10 min | | +**A later tag catches up a forgotten one** (`LegPass.caughtUp`, 2026-11-01: +shower tag forgotten, re-rung while dressing). The weigh-in never catches +up, and the shower's 8-minute floor still applies. + Legs latch like the ring's stages. A leg whose tag is not registered is **skipped at start** (`LegPass.skipped`), so the feature is usable before it is configured: with no session tags at all it is a weigh-in and nothing @@ -58,6 +62,8 @@ the first leg by itself — there is nothing to walk to. - **Silencing a re-ring is the full dismiss sequence**: tag 1, the walk, tag 2, three questions. The ring screen's `_finishDismiss` sees a live session for today and resumes it with `reRings + 1`. +- **A silent buzz 3 min before each deadline** (`SessionWarn.kt`, request + code 1004), armed and cleared only with the re-ring. - **One re-ring per leg.** After a deadline re-ring is dismissed the leg is in `deadlineRung`; 5 min after its deadline (`bypassGrace`) it passes as `LegPass.bypassed` — the dead-tag hatch, paid for with one full ring. diff --git a/phone_app/android/app/src/main/AndroidManifest.xml b/phone_app/android/app/src/main/AndroidManifest.xml index 571da35..879baf9 100644 --- a/phone_app/android/app/src/main/AndroidManifest.xml +++ b/phone_app/android/app/src/main/AndroidManifest.xml @@ -94,6 +94,10 @@ + + diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt index 0881bcd..40339ca 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt @@ -119,6 +119,7 @@ object AlarmScheduler { AlarmManager.AlarmClockInfo(atMillis, showIntent(context)), reRingPendingIntent(context), ) + SessionWarn.arm(context, atMillis) } /** @@ -129,6 +130,7 @@ object AlarmScheduler { fun clearReRing(context: Context) { val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager manager.cancel(reRingPendingIntent(context)) + SessionWarn.clear(context) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() .remove(KEY_RERING_AT).apply() } diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionWarn.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionWarn.kt new file mode 100644 index 0000000..8976f45 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionWarn.kt @@ -0,0 +1,85 @@ +package com.kuhy.wake_alarm_sync + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.os.Build +import android.os.VibrationAttributes +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log + +/** + * A silent buzz [LEAD_MS] before each morning-session deadline. + * + * Armed and cleared only alongside the re-ring in [AlarmScheduler], so it + * cannot outlive or drift from the deadline it warns about. + */ +object SessionWarn { + private const val TAG = "WakeAlarmSession" + + /** Distinct from 1001 (daily) and 1003 (re-ring): PendingIntents key on it. */ + private const val REQUEST_CODE = 1004 + + const val LEAD_MS = 3 * 60_000L + + private val PATTERN = longArrayOf(0, 400, 200, 400, 200, 400) + + /** The buzz instant, or null once it has passed (BOOT_COMPLETED replays). */ + fun warnAt(deadlineMs: Long, nowMs: Long): Long? = + (deadlineMs - LEAD_MS).takeIf { it > nowMs } + + fun arm(context: Context, deadlineMs: Long) { + val at = warnAt(deadlineMs, System.currentTimeMillis()) + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + if (at == null) { + manager.cancel(pendingIntent(context)) + return + } + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pendingIntent(context)) + } + + fun clear(context: Context) { + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + manager.cancel(pendingIntent(context)) + } + + internal fun buzz(context: Context) { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager) + ?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } ?: return + val effect = VibrationEffect.createWaveform(PATTERN, -1) + // Alarm usage, so Do Not Disturb and a backgrounded app still buzz. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vibrator.vibrate(effect, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ALARM)) + } else { + val audio = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build() + @Suppress("DEPRECATION") + vibrator.vibrate(effect, audio) + } + Log.i(TAG, "deadline warning buzzed") + } + + private fun pendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + REQUEST_CODE, + Intent(context, SessionWarnReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +/** Buzzes only while today's session is live; a stale alarm stays silent. */ +class SessionWarnReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!SessionNotice.isLiveToday(context) || AlarmService.running) return + SessionWarn.buzz(context) + } +} diff --git a/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/SessionWarnTest.kt b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/SessionWarnTest.kt new file mode 100644 index 0000000..036ee33 --- /dev/null +++ b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/SessionWarnTest.kt @@ -0,0 +1,24 @@ +package com.kuhy.wake_alarm_sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionWarnTest { + private val deadline = 1_000_000_000L + + @Test + fun warnsThreeMinutesBeforeTheDeadline() { + assertEquals(deadline - 180_000L, SessionWarn.warnAt(deadline, deadline - 600_000L)) + } + + @Test + fun aWarnInstantAlreadyPastIsDropped() { + assertNull(SessionWarn.warnAt(deadline, deadline - 60_000L)) + } + + @Test + fun aDeadlineAlreadyPastIsDropped() { + assertNull(SessionWarn.warnAt(deadline, deadline + 1L)) + } +} diff --git a/phone_app/lib/services/morning_session.dart b/phone_app/lib/services/morning_session.dart index 0a7bcd9..1661b91 100644 --- a/phone_app/lib/services/morning_session.dart +++ b/phone_app/lib/services/morning_session.dart @@ -43,6 +43,9 @@ enum LegPass { /// Never satisfied; passed by the clock after a missed deadline. bypassed, + + /// Forgotten, then covered by a later room's tag once its floor had passed. + caughtUp, } /// Where the session stands. diff --git a/phone_app/lib/services/session_rules.dart b/phone_app/lib/services/session_rules.dart index b04f36c..2a9ff8b 100644 --- a/phone_app/lib/services/session_rules.dart +++ b/phone_app/lib/services/session_rules.dart @@ -84,7 +84,17 @@ extension SessionRules on MorningSession { String onTag(SessionTagSlot slot, DateTime now) { final leg = currentLeg; if (leg == null) return 'Session finished'; - if (slot != leg.slot) return 'Not the ${leg.slot.name} tag'; + if (slot != leg.slot) { + final ahead = _catchUpTo(slot, now); + if (ahead == null) return 'Not the ${leg.slot.name} tag'; + for (var i = leg.index; i < ahead.index; i++) { + final skipped = SessionLeg.values[i]; + if (!passedAt.containsKey(skipped)) { + _pass(skipped, now, LegPass.caughtUp); + } + } + return onTag(slot, now); + } switch (leg) { case SessionLeg.bathroomIn: final typedAt = pendingWeightAt; @@ -175,6 +185,20 @@ extension SessionRules on MorningSession { return deadline(leg); } + /// The later leg [slot] covers; the weigh-in and shower floor never skip. + SessionLeg? _catchUpTo(SessionTagSlot slot, DateTime now) { + final leg = currentLeg!; + for (var i = leg.index; i < SessionLeg.values.length; i++) { + final next = SessionLeg.values[i]; + if (i > leg.index && next.slot == slot) return next; + if (passedAt.containsKey(next)) continue; + if (next == SessionLeg.bathroomIn) return null; + final floor = earliest(next); + if (floor != null && now.isBefore(floor)) return null; + } + return null; + } + void _pass(SessionLeg leg, DateTime now, LegPass how) { passedAt[leg] = now; passedHow[leg] = how; diff --git a/phone_app/test/services/session_catch_up_test.dart b/phone_app/test/services/session_catch_up_test.dart new file mode 100644 index 0000000..575efb7 --- /dev/null +++ b/phone_app/test/services/session_catch_up_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/morning_session.dart'; +import 'package:wake_alarm_sync/services/session_json.dart'; +import 'package:wake_alarm_sync/services/session_rules.dart'; +import 'package:wake_alarm_sync/services/session_tag_settings.dart'; + +final DateTime t0 = DateTime(2026, 9, 17, 8, 5); +const SessionTagSettings allTags = SessionTagSettings( + tagIds: ['bath', 'ward', 'desk'], +); + +MorningSession fresh({SessionTagSettings tags = allTags, bool nfc = true}) => + SessionRules.start(now: t0, tags: tags, nfcAvailable: nfc); + +/// Weighs in and taps the bathroom tag, leaving the shower leg live. +MorningSession inBathroom() => fresh() + ..enterCheckIn(72, 3, t0) + ..onTag(SessionTagSlot.bathroom, t0.add(const Duration(seconds: 30))); + +void main() { + group('catch-up', () { + test('a later tag catches up a forgotten shower tag', () { + final s = inBathroom(); + final late = s + .earliest(SessionLeg.showerDone)! + .add(const Duration(minutes: 4)); + expect( + s.onTag(SessionTagSlot.wardrobe, late), + 'Done: ${SessionLeg.dressed.label}', + ); + expect(s.passedHow[SessionLeg.showerDone], LegPass.caughtUp); + expect(s.passedHow[SessionLeg.dressed], LegPass.tag); + expect(s.currentLeg, SessionLeg.atDesk); + expect(s.nextDeadline(), late.add(MorningSession.legWindow)); + expect(s.reRings, 0); + }); + + test('the desk tag catches up every leg before it', () { + final s = inBathroom(); + final late = s.earliest(SessionLeg.showerDone)!; + expect(s.onTag(SessionTagSlot.desk, late), contains('complete')); + expect(s.passedHow[SessionLeg.showerDone], LegPass.caughtUp); + expect(s.passedHow[SessionLeg.dressed], LegPass.caughtUp); + expect(s.outcome, SessionOutcome.completed); + }); + + test('catch-up still waits out the shower floor', () { + final s = inBathroom(); + final early = s + .earliest(SessionLeg.showerDone)! + .subtract(const Duration(seconds: 1)); + expect(s.onTag(SessionTagSlot.wardrobe, early), 'Not the bathroom tag'); + expect(s.currentLeg, SessionLeg.showerDone); + }); + + test('the weigh-in is never caught up', () { + final s = fresh()..enterCheckIn(72, 3, t0); + expect(s.onTag(SessionTagSlot.desk, t0), 'Not the bathroom tag'); + expect(s.currentLeg, SessionLeg.bathroomIn); + }); + + test('a caught-up leg survives the store', () { + final s = inBathroom() + ..onTag(SessionTagSlot.wardrobe, t0.add(const Duration(minutes: 9))); + final back = sessionFromJson(s.toJson())!; + expect(back.passedHow[SessionLeg.showerDone], LegPass.caughtUp); + }); + }); +} diff --git a/phone_app/test/session_screen_catch_up_test.dart b/phone_app/test/session_screen_catch_up_test.dart new file mode 100644 index 0000000..50b71b1 --- /dev/null +++ b/phone_app/test/session_screen_catch_up_test.dart @@ -0,0 +1,94 @@ +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_store.dart'; +import 'package:wake_alarm_sync/services/session_tag_settings.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List calls; + late DateTime now; + var ringing = false; + + void mockChannel() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async { + calls.add(call); + if (call.method == 'hasNfc') return true; + if (call.method == 'isRinging') return ringing; + return null; + }, + ); + } + + /// Delivers a tag from the platform side, as `NfcReader` does. + Future tap(String tagId) async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + AlarmScheduler.channelName, + codec.encodeMethodCall(MethodCall('onNfcTag', tagId)), + (_) {}, + ); + } + + List methods() => calls.map((c) => c.method).toList(); + + setUp(() async { + calls = []; + now = DateTime(2026, 9, 17, 8, 4); + ringing = false; + SharedPreferences.setMockInitialValues({}); + mockChannel(); + await SessionTagSettings.replaceAll(['bath', 'ward', 'desk'], ''); + }); + + Future pumpSession(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + routes: { + '/': (_) => + SessionScreen(clock: () => now, publish: () async => 'pushed'), + '/ring': (_) => const Scaffold(body: Text('RING')), + }, + ), + ); + await tester.pump(); + await tester.pump(); + } + + /// Taps energy 3 (required since the check-in) and submits [kg]. + Future logWeight(WidgetTester tester, String kg) async { + await tester.tap(find.widgetWithText(ChoiceChip, '3')); + await tester.pump(); + await tester.enterText(find.byType(TextField), kg); + await tester.tap(find.text('Log weight')); + await tester.pump(); + } + + testWidgets('a forgotten shower tag is caught up by the wardrobe', ( + tester, + ) async { + await pumpSession(tester); + await logWeight(tester, '72'); + await tap('bath'); + await tester.pump(); + now = now.add(const Duration(minutes: 9)); + await tap('ward'); + await tester.pump(); + final saved = (await SessionStore().loadCurrent())!; + expect(saved.passedHow[SessionLeg.showerDone], LegPass.caughtUp); + expect(saved.currentLeg, SessionLeg.atDesk); + final arm = calls.lastWhere((c) => c.method == 'armReRing'); + final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch; + expect(arm.arguments, {'atMillis': deadline}); + expect(methods(), isNot(contains('ringNow'))); + }); +}