mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Session: tag catch-up + pre-deadline buzz
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c2197a3cd
commit
69be24d4c0
@ -25,6 +25,10 @@ weigh in -> bathroom tag (≤ 2 min later) -> shower -> bathroom tag again
|
|||||||
| `dressed` | wardrobe tag | previous + 10 min | |
|
| `dressed` | wardrobe tag | previous + 10 min | |
|
||||||
| `atDesk` | desk 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
|
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
|
**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
|
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,
|
- **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`.
|
||||||
|
- **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
|
- **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
|
in `deadlineRung`; 5 min after its deadline (`bypassGrace`) it passes as
|
||||||
`LegPass.bypassed` — the dead-tag hatch, paid for with one full ring.
|
`LegPass.bypassed` — the dead-tag hatch, paid for with one full ring.
|
||||||
|
|||||||
@ -94,6 +94,10 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".PoolSyncReceiver"
|
android:name=".PoolSyncReceiver"
|
||||||
android:exported="false"/>
|
android:exported="false"/>
|
||||||
|
<!-- The silent buzz before a session deadline (SessionWarn.kt). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".SessionWarnReceiver"
|
||||||
|
android:exported="false"/>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".BootReceiver"
|
android:name=".BootReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@ -119,6 +119,7 @@ object AlarmScheduler {
|
|||||||
AlarmManager.AlarmClockInfo(atMillis, showIntent(context)),
|
AlarmManager.AlarmClockInfo(atMillis, showIntent(context)),
|
||||||
reRingPendingIntent(context),
|
reRingPendingIntent(context),
|
||||||
)
|
)
|
||||||
|
SessionWarn.arm(context, atMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -129,6 +130,7 @@ object AlarmScheduler {
|
|||||||
fun clearReRing(context: Context) {
|
fun clearReRing(context: Context) {
|
||||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
manager.cancel(reRingPendingIntent(context))
|
manager.cancel(reRingPendingIntent(context))
|
||||||
|
SessionWarn.clear(context)
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
.remove(KEY_RERING_AT).apply()
|
.remove(KEY_RERING_AT).apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,9 @@ enum LegPass {
|
|||||||
|
|
||||||
/// Never satisfied; passed by the clock after a missed deadline.
|
/// Never satisfied; passed by the clock after a missed deadline.
|
||||||
bypassed,
|
bypassed,
|
||||||
|
|
||||||
|
/// Forgotten, then covered by a later room's tag once its floor had passed.
|
||||||
|
caughtUp,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the session stands.
|
/// Where the session stands.
|
||||||
|
|||||||
@ -84,7 +84,17 @@ extension SessionRules on MorningSession {
|
|||||||
String onTag(SessionTagSlot slot, DateTime now) {
|
String onTag(SessionTagSlot slot, DateTime now) {
|
||||||
final leg = currentLeg;
|
final leg = currentLeg;
|
||||||
if (leg == null) return 'Session finished';
|
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) {
|
switch (leg) {
|
||||||
case SessionLeg.bathroomIn:
|
case SessionLeg.bathroomIn:
|
||||||
final typedAt = pendingWeightAt;
|
final typedAt = pendingWeightAt;
|
||||||
@ -175,6 +185,20 @@ extension SessionRules on MorningSession {
|
|||||||
return deadline(leg);
|
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) {
|
void _pass(SessionLeg leg, DateTime now, LegPass how) {
|
||||||
passedAt[leg] = now;
|
passedAt[leg] = now;
|
||||||
passedHow[leg] = how;
|
passedHow[leg] = how;
|
||||||
|
|||||||
69
phone_app/test/services/session_catch_up_test.dart
Normal file
69
phone_app/test/services/session_catch_up_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
94
phone_app/test/session_screen_catch_up_test.dart
Normal file
94
phone_app/test/session_screen_catch_up_test.dart
Normal 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')));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user