Skip to content

Commit 054cfb6

Browse files
committed
Gate phase transitions on alarm stop
1 parent 4cf2f30 commit 054cfb6

6 files changed

Lines changed: 155 additions & 19 deletions

File tree

work_timer/android/app/src/main/kotlin/com/utsapoddar/sift/TimerService.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class TimerService : Service() {
4242
private var wakeLock: android.os.PowerManager.WakeLock? = null
4343
private var audioFocusRequest: AudioFocusRequest? = null
4444
private var vibrator: Vibrator? = null
45+
private var alarmGateStartedAt: Long? = null
4546

4647
override fun onBind(intent: Intent?): IBinder? = null
4748

@@ -74,6 +75,8 @@ class TimerService : Service() {
7475
when (intent.action) {
7576
ACTION_ALARM_FIRED -> {
7677
currentPhaseName = intent.getStringExtra(EXTRA_PHASE_NAME) ?: currentPhaseName
78+
alarmGateStartedAt = System.currentTimeMillis()
79+
cancelAlarms()
7780
updateNotification()
7881
playAlarm()
7982
// Notify Flutter if it's alive
@@ -92,6 +95,7 @@ class TimerService : Service() {
9295
}
9396
ACTION_SILENCE -> {
9497
stopAlarmSound()
98+
resumeAlarmsAfterGate()
9599
// Also notify Flutter so the UI can react
96100
sendBroadcast(Intent("com.sift.timer.alarm_notify").setPackage(packageName).apply {
97101
action = ACTION_SILENCE
@@ -208,6 +212,24 @@ class TimerService : Service() {
208212
getSharedPreferences("sift_boot", Context.MODE_PRIVATE).edit().clear().apply()
209213
}
210214

215+
private fun resumeAlarmsAfterGate() {
216+
val gateStartedAt = alarmGateStartedAt ?: return
217+
val elapsedMs = (System.currentTimeMillis() - gateStartedAt).coerceAtLeast(0L)
218+
val prefs = getSharedPreferences("sift_boot", Context.MODE_PRIVATE)
219+
val namesStr = prefs.getString("phase_names", null) ?: return
220+
val timesStr = prefs.getString("phase_times", null) ?: return
221+
val names = namesStr.split(",").toTypedArray()
222+
val shiftedTimes = timesStr.split(",")
223+
.mapNotNull { it.toLongOrNull() }
224+
.map { if (it > gateStartedAt) it + elapsedMs else it }
225+
.toLongArray()
226+
if (shiftedTimes.isNotEmpty()) {
227+
scheduleAlarms(names, shiftedTimes)
228+
savePhaseDataForBoot(names, shiftedTimes)
229+
}
230+
alarmGateStartedAt = null
231+
}
232+
211233
private fun playAlarm() {
212234
stopAlarmSound()
213235
requestAlarmAudioFocus()
@@ -253,7 +275,7 @@ class TimerService : Service() {
253275
}
254276
}
255277
mediaPlayer?.apply {
256-
isLooping = false
278+
isLooping = true
257279
setOnCompletionListener { mp ->
258280
if (mediaPlayer == mp) {
259281
try { mp.release() } catch (_: Exception) {}

work_timer/lib/screens/home_screen.dart

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import '../services/durable_stats.dart';
1515
import '../services/milestones.dart';
1616
import '../services/live_activity.dart';
1717
import '../services/notifications.dart';
18+
import '../services/phase_alarm_gate.dart';
1819
import 'milestone_screen.dart';
1920

2021
const _accent = Color(0xFFF97316);
@@ -44,6 +45,9 @@ class _HomeScreenState extends State<HomeScreen>
4445
int _totalPausedMs = 0;
4546
bool _alarmPlaying = false;
4647
bool _sessionComplete = false;
48+
bool _waitingForAlarmStop = false;
49+
DateTime? _alarmGateStart;
50+
int? _pendingPhaseIndexAfterAlarm;
4751
int _lastPhaseIndex = -1;
4852
String? _ringtonePath;
4953
StreamSubscription<void>? _alarmCompleteSub;
@@ -89,7 +93,7 @@ class _HomeScreenState extends State<HomeScreen>
8993
if (!mounted) return;
9094
if (_sessionComplete) {
9195
_stop();
92-
} else {
96+
} else if (!_waitingForAlarmStop) {
9397
setState(() => _alarmPlaying = false);
9498
}
9599
});
@@ -527,6 +531,9 @@ class _HomeScreenState extends State<HomeScreen>
527531
_pauseStart = null;
528532
_totalPausedMs = 0;
529533
_sessionComplete = false;
534+
_waitingForAlarmStop = false;
535+
_alarmGateStart = null;
536+
_pendingPhaseIndexAfterAlarm = null;
530537
_lastPhaseIndex = -1;
531538
});
532539
await _persistActiveSession();
@@ -561,9 +568,17 @@ class _HomeScreenState extends State<HomeScreen>
561568
}
562569
}
563570

564-
Future<void> _triggerAlarm() async {
565-
setState(() => _alarmPlaying = true);
566-
await playAlarm();
571+
Future<void> _triggerAlarm({required int pendingPhaseIndex}) async {
572+
_ticker?.cancel();
573+
_ticker = null;
574+
setState(() {
575+
_waitingForAlarmStop = true;
576+
_alarmGateStart = DateTime.now();
577+
_pendingPhaseIndexAfterAlarm = pendingPhaseIndex;
578+
_alarmPlaying = true;
579+
});
580+
unawaited(_persistActiveSession());
581+
await playAlarm(loop: true);
567582
}
568583

569584
Future<void> _stopAlarm({bool nativeOrigin = false}) async {
@@ -573,9 +588,56 @@ class _HomeScreenState extends State<HomeScreen>
573588
}
574589
if (_sessionComplete) {
575590
_stop();
576-
} else {
577-
setState(() => _alarmPlaying = false);
591+
return;
592+
}
593+
594+
final schedule = _schedule;
595+
final gateStart = _alarmGateStart;
596+
final pendingPhaseIndex = _pendingPhaseIndexAfterAlarm;
597+
if (_waitingForAlarmStop &&
598+
schedule != null &&
599+
gateStart != null &&
600+
pendingPhaseIndex != null &&
601+
pendingPhaseIndex < schedule.phases.length) {
602+
final resolved = resolvePhaseAlarmGate(
603+
totalPausedMs: _totalPausedMs,
604+
gateStartedAt: gateStart,
605+
stoppedAt: DateTime.now(),
606+
pendingPhaseIndex: pendingPhaseIndex,
607+
);
608+
_totalPausedMs = resolved.totalPausedMs;
609+
_lastPhaseIndex = resolved.activePhaseIndex;
610+
final now = DateTime.now().subtract(
611+
Duration(milliseconds: _totalPausedMs),
612+
);
613+
final phase = schedule.phases[resolved.activePhaseIndex];
614+
setState(() {
615+
_waitingForAlarmStop = false;
616+
_alarmGateStart = null;
617+
_pendingPhaseIndexAfterAlarm = null;
618+
_alarmPlaying = false;
619+
_phaseIndex = resolved.activePhaseIndex;
620+
_remaining = phase.remaining(now);
621+
_phaseProgress = phase.progress(now);
622+
});
623+
unawaited(_persistActiveSession());
624+
unawaited(scheduleAll(schedule, offsetMs: _totalPausedMs));
625+
unawaited(
626+
startTimerService(
627+
phaseNames: schedule.phases.map((p) => p.phase.name).toList(),
628+
phaseEndTimes: shiftedPhaseEndTimes(schedule, _totalPausedMs),
629+
),
630+
);
631+
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
632+
return;
578633
}
634+
635+
setState(() {
636+
_waitingForAlarmStop = false;
637+
_alarmGateStart = null;
638+
_pendingPhaseIndexAfterAlarm = null;
639+
_alarmPlaying = false;
640+
});
579641
}
580642

581643
void _stop() async {
@@ -599,6 +661,9 @@ class _HomeScreenState extends State<HomeScreen>
599661
_pauseStart = null;
600662
_totalPausedMs = 0;
601663
_alarmPlaying = false;
664+
_waitingForAlarmStop = false;
665+
_alarmGateStart = null;
666+
_pendingPhaseIndexAfterAlarm = null;
602667
_sessionComplete = false;
603668
_pendingMilestone = null;
604669
_phaseIndex = 0;
@@ -688,7 +753,7 @@ class _HomeScreenState extends State<HomeScreen>
688753

689754
void _tick() {
690755
final schedule = _schedule;
691-
if (schedule == null) return;
756+
if (schedule == null || _waitingForAlarmStop) return;
692757

693758
final now = DateTime.now().subtract(Duration(milliseconds: _totalPausedMs));
694759
final idx = schedule.currentPhaseIndex(now);
@@ -699,7 +764,7 @@ class _HomeScreenState extends State<HomeScreen>
699764
unawaited(_onSessionComplete());
700765
setState(() => _sessionComplete = true);
701766
unawaited(cancelNotification(schedule.phases.length - 1));
702-
_triggerAlarm();
767+
_triggerAlarm(pendingPhaseIndex: schedule.phases.length);
703768
return;
704769
}
705770

@@ -708,18 +773,24 @@ class _HomeScreenState extends State<HomeScreen>
708773
final progress = phase.progress(now);
709774

710775
if (idx != _lastPhaseIndex && _lastPhaseIndex != -1) {
711-
// Cancel the notification for the completed phase — app handles alarm in-app
776+
final completedPhase = schedule.phases[_lastPhaseIndex];
712777
unawaited(cancelNotification(_lastPhaseIndex));
713-
_triggerAlarm();
778+
setState(() {
779+
_phaseIndex = _lastPhaseIndex;
780+
_remaining = Duration.zero;
781+
_phaseProgress = 1.0;
782+
});
714783
updateLiveActivity(
715-
phaseName: phase.phase.name,
716-
phaseEndTime: phase.endTime,
717-
remainingSeconds: remaining.inSeconds,
718-
totalSeconds: phase.phase.duration.inSeconds,
719-
isBreak: phase.phase.isBreak,
784+
phaseName: completedPhase.phase.name,
785+
phaseEndTime: completedPhase.endTime,
786+
remainingSeconds: 0,
787+
totalSeconds: completedPhase.phase.duration.inSeconds,
788+
isBreak: completedPhase.phase.isBreak,
720789
isPaused: false,
721-
alarmPlaying: _alarmPlaying,
790+
alarmPlaying: true,
722791
);
792+
_triggerAlarm(pendingPhaseIndex: idx);
793+
return;
723794
}
724795
_lastPhaseIndex = idx;
725796

work_timer/lib/services/audio.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ void _ensureVibrationStopsOnCompletion() {
1818
});
1919
}
2020

21-
Future<void> playAlarm() async {
21+
Future<void> playAlarm({bool loop = false}) async {
2222
_ensureVibrationStopsOnCompletion();
2323
try {
2424
await _player.stop();
25+
await _player.setReleaseMode(loop ? ReleaseMode.loop : ReleaseMode.release);
2526
stopAlarmVibration();
2627
if (Platform.isAndroid) {
2728
await _player.setAudioContext(
@@ -64,5 +65,6 @@ Future<void> stopAlarm() async {
6465
stopAlarmVibration();
6566
try {
6667
await _player.stop();
68+
await _player.setReleaseMode(ReleaseMode.release);
6769
} catch (_) {}
6870
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class PhaseAlarmGateResolution {
2+
final int totalPausedMs;
3+
final int activePhaseIndex;
4+
5+
const PhaseAlarmGateResolution({
6+
required this.totalPausedMs,
7+
required this.activePhaseIndex,
8+
});
9+
}
10+
11+
PhaseAlarmGateResolution resolvePhaseAlarmGate({
12+
required int totalPausedMs,
13+
required DateTime gateStartedAt,
14+
required DateTime stoppedAt,
15+
required int pendingPhaseIndex,
16+
}) {
17+
final gateMs = stoppedAt.difference(gateStartedAt).inMilliseconds;
18+
return PhaseAlarmGateResolution(
19+
totalPausedMs: totalPausedMs + (gateMs < 0 ? 0 : gateMs),
20+
activePhaseIndex: pendingPhaseIndex,
21+
);
22+
}

work_timer/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: work_timer
22
description: "Work session timer with automatic break scheduling."
33
publish_to: 'none'
44

5-
version: 1.0.15+15
5+
version: 1.0.16+16
66
environment:
77
sdk: ^3.11.3
88

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:work_timer/services/phase_alarm_gate.dart';
3+
4+
void main() {
5+
test('stopping a phase alarm shifts future phases by alarm wait time', () {
6+
final gateStart = DateTime.fromMillisecondsSinceEpoch(10000);
7+
final stoppedAt = DateTime.fromMillisecondsSinceEpoch(25000);
8+
9+
final resolved = resolvePhaseAlarmGate(
10+
totalPausedMs: 2000,
11+
gateStartedAt: gateStart,
12+
stoppedAt: stoppedAt,
13+
pendingPhaseIndex: 1,
14+
);
15+
16+
expect(resolved.totalPausedMs, 17000);
17+
expect(resolved.activePhaseIndex, 1);
18+
});
19+
}

0 commit comments

Comments
 (0)