mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +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 | |
|
||||
| `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.
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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.
|
||||
bypassed,
|
||||
|
||||
/// Forgotten, then covered by a later room's tag once its floor had passed.
|
||||
caughtUp,
|
||||
}
|
||||
|
||||
/// Where the session stands.
|
||||
|
||||
@ -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;
|
||||
|
||||
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