Session: tag catch-up + pre-deadline buzz

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 18:25:15 +01:00
parent 1c2197a3cd
commit 69be24d4c0
9 changed files with 312 additions and 1 deletions

View File

@ -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.

View File

@ -94,6 +94,10 @@
<receiver
android:name=".PoolSyncReceiver"
android:exported="false"/>
<!-- The silent buzz before a session deadline (SessionWarn.kt). -->
<receiver
android:name=".SessionWarnReceiver"
android:exported="false"/>
<receiver
android:name=".BootReceiver"
android:exported="true">

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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.

View File

@ -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;

View File

@ -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: <String>['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);
});
});
}

View File

@ -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<MethodCall> 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<void> tap(String tagId) async {
const codec = StandardMethodCodec();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
AlarmScheduler.channelName,
codec.encodeMethodCall(MethodCall('onNfcTag', tagId)),
(_) {},
);
}
List<String> methods() => calls.map((c) => c.method).toList();
setUp(() async {
calls = <MethodCall>[];
now = DateTime(2026, 9, 17, 8, 4);
ringing = false;
SharedPreferences.setMockInitialValues(<String, Object>{});
mockChannel();
await SessionTagSettings.replaceAll(<String>['bath', 'ward', 'desk'], '');
});
Future<void> pumpSession(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
routes: <String, WidgetBuilder>{
'/': (_) =>
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<void> 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, <String, int?>{'atMillis': deadline});
expect(methods(), isNot(contains('ringNow')));
});
}