From 6f0cc0c29d4a27ff5c7bd58813f3e048d7663448 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Tue, 5 May 2026 14:45:58 +0900 Subject: [PATCH] fix: handle missing schedule alarm fallback --- .../alarm/screens/alarm_screen.dart | 40 +++++++-- ...sue-363-alarm-no-schedule-fallback-plan.md | 36 ++++++++ .../screens/preparation_flow_widget_test.dart | 84 ++++++++++++++++++- 3 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 plans/issue-363-alarm-no-schedule-fallback-plan.md diff --git a/lib/presentation/alarm/screens/alarm_screen.dart b/lib/presentation/alarm/screens/alarm_screen.dart index 6b352cdb..58786fa1 100644 --- a/lib/presentation/alarm/screens/alarm_screen.dart +++ b/lib/presentation/alarm/screens/alarm_screen.dart @@ -32,6 +32,7 @@ class _AlarmScreenState extends State { bool _hasShownCompletionDialog = false; bool _isContinuingAfterCompletion = false; bool _navigateAfterFinish = false; + bool _didNavigateForNotExistsTransition = false; int? _pendingEarlyLateSeconds; bool? _pendingIsLate; Timer? _uiTickerTimer; @@ -48,6 +49,13 @@ class _AlarmScreenState extends State { _isContinuingAfterCompletion = false; } + void _navigateHomeAfterFrame(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !context.mounted) return; + context.go('/home'); + }); + } + Duration _timeRemainingBeforeLeaving(ScheduleWithPreparationEntity schedule) { return schedule.timeRemainingBeforeLeavingAt(widget.nowProvider()); } @@ -120,6 +128,7 @@ class _AlarmScreenState extends State { listener: (context, scheduleState) { final earlyLateSeconds = _pendingEarlyLateSeconds; final isLate = _pendingIsLate; + _didNavigateForNotExistsTransition = true; if (_navigateAfterFinish && earlyLateSeconds != null && @@ -145,6 +154,7 @@ class _AlarmScreenState extends State { final schedule = scheduleState.schedule!; final preparation = schedule.preparation; final scheduleChanged = _completionScheduleId != schedule.id; + _didNavigateForNotExistsTransition = false; if (scheduleChanged) { _completionScheduleId = schedule.id; @@ -181,19 +191,32 @@ class _AlarmScreenState extends State { }); } - _ensureUiTicker(preparation.isAllStepsDone && - _isContinuingAfterCompletion); + _ensureUiTicker( + preparation.isAllStepsDone && _isContinuingAfterCompletion); return _buildAlarmScreen( schedule: schedule, ); } else if (scheduleState.status == ScheduleStatus.upcoming && scheduleState.schedule != null) { _completionScheduleId = scheduleState.schedule!.id; + _didNavigateForNotExistsTransition = false; _resetCompletionUiState(); _ensureUiTicker(true); return _buildEarlyStartReadyScreen(scheduleState.schedule!); + } else if (scheduleState.status == ScheduleStatus.notExists) { + _completionScheduleId = null; + _resetCompletionUiState(); + _ensureUiTicker(false); + if (!_navigateAfterFinish && !_didNavigateForNotExistsTransition) { + _navigateHomeAfterFrame(context); + } + return const Scaffold( + backgroundColor: Color(0xff5C79FB), + body: Center(child: CircularProgressIndicator()), + ); } else { _completionScheduleId = null; + _didNavigateForNotExistsTransition = false; _resetCompletionUiState(); _ensureUiTicker(false); return const Scaffold( @@ -241,10 +264,10 @@ class _AlarmScreenState extends State { final timerLabel = isLateContinueMode ? '지각이에요' : preparation.currentStepName; final displayProgress = isLateContinueMode ? 0.0 : preparation.progress; - final displayRemainingSeconds = preparation.isAllStepsDone && - _isContinuingAfterCompletion - ? timeRemainingBeforeLeaving.inSeconds.abs() - : preparation.currentStepRemainingTime.inSeconds; + final displayRemainingSeconds = + preparation.isAllStepsDone && _isContinuingAfterCompletion + ? timeRemainingBeforeLeaving.inSeconds.abs() + : preparation.currentStepRemainingTime.inSeconds; if (!(preparation.isAllStepsDone && _isContinuingAfterCompletion)) { _ensureUiTicker(false); @@ -315,8 +338,9 @@ class _AlarmScreenState extends State { Widget _buildEarlyStartReadyScreen(ScheduleWithPreparationEntity schedule) { final l10n = AppLocalizations.of(context)!; final theme = Theme.of(context); - final remaining = - schedule.preparationStartTime.difference(widget.nowProvider()).inSeconds; + final remaining = schedule.preparationStartTime + .difference(widget.nowProvider()) + .inSeconds; final clampedRemaining = remaining.isNegative ? 0 : remaining; return Scaffold( diff --git a/plans/issue-363-alarm-no-schedule-fallback-plan.md b/plans/issue-363-alarm-no-schedule-fallback-plan.md new file mode 100644 index 00000000..1dd84908 --- /dev/null +++ b/plans/issue-363-alarm-no-schedule-fallback-plan.md @@ -0,0 +1,36 @@ +# Issue 363 Alarm No-Schedule Fallback Plan + +## Goal +Ensure `/alarmScreen` navigates back to `/home` when it is opened while `ScheduleBloc.state.status` is already `ScheduleStatus.notExists`, instead of leaving the user on the loading spinner. + +Issue: https://github.com/DevKor-github/OnTime-front/issues/363 + +## Context +- The issue covers stale notification, deleted schedule, and already-ended schedule entry paths. +- `AlarmScreen` currently handles transitions into `notExists` in `lib/presentation/alarm/screens/alarm_screen.dart`, but its `BlocListener.listenWhen` only fires when the previous status was not `notExists`. +- When the alarm route is built with an already-`notExists` state, no listener event fires and the `BlocBuilder` falls through to the loading scaffold. +- Finish flow uses `_navigateAfterFinish`, `_pendingEarlyLateSeconds`, and `_pendingIsLate` to route to `/earlyLate` after `ScheduleFinished` emits `notExists`; this path must remain higher priority than the home fallback. +- Existing alarm widget coverage lives in `test/presentation/alarm/screens/preparation_flow_widget_test.dart`. + +## Decisions +- Treat an already-`notExists` state on alarm route entry as terminal for `/alarmScreen`. +- Trigger the fallback with a post-frame callback, because navigation should not happen synchronously during build. +- Keep the existing transition listener for active schedule flows that later become `notExists`. +- Preserve `_navigateAfterFinish` handling so manual or dialog finish still routes to `/earlyLate`. +- Scope the implementation to `AlarmScreen` and alarm widget tests; no bloc behavior change is needed. + +## Steps +1. In `AlarmScreen`, add a helper such as `_navigateHomeAfterFrameIfMounted(BuildContext context)` to centralize safe post-frame home navigation. +2. In the `BlocBuilder` branch for `ScheduleStatus.notExists`, check that `_navigateAfterFinish` is false, clear transient alarm UI state, stop the UI ticker, and schedule the home navigation after the current frame. +3. Leave the current `BlocListener` in place for `ongoing`, `started`, or `upcoming` states that later emit `notExists`. +4. Make sure the loading scaffold remains only for genuinely unresolved statuses, especially `ScheduleStatus.initial`. +5. Add a widget test that seeds the alarm bloc with `const ScheduleState.notExists()` before pumping `/alarmScreen`, then expects `/home`. +6. Add a stale-notification-style widget test with a stream that emits `null` while the bloc is already `notExists`, then confirms `/home` and no finish-use-case call. +7. Re-run existing finish navigation tests, especially manual finish and completion dialog finish cases, to confirm `/earlyLate` remains the result when `_navigateAfterFinish` is pending. + +## Validation +- `flutter test test/presentation/alarm/screens/preparation_flow_widget_test.dart` +- `flutter analyze` + +## Open Questions +- None. diff --git a/test/presentation/alarm/screens/preparation_flow_widget_test.dart b/test/presentation/alarm/screens/preparation_flow_widget_test.dart index bf4c6794..de6435c1 100644 --- a/test/presentation/alarm/screens/preparation_flow_widget_test.dart +++ b/test/presentation/alarm/screens/preparation_flow_widget_test.dart @@ -1143,7 +1143,9 @@ void main() { const Color(0xFFFF6953).value, ); expect( - tester.widget(find.byType(AlarmGraphAnimator)).progress, + tester + .widget(find.byType(AlarmGraphAnimator)) + .progress, 0.0, ); expect( @@ -1225,6 +1227,86 @@ void main() { expect(finishUseCase.calls.length, 1); }, timeout: const Timeout(Duration(seconds: 15))); + testWidgets('already missing schedule on alarm entry navigates home', + (tester) async { + await setLargeTestViewport(tester); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), + GoRoute( + path: '/earlyLate', builder: (_, __) => const Text('EARLYLATE')), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + )..emit(const ScheduleState.notExists()); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilRouteText(tester, 'HOME'); + + expect(find.text('HOME'), findsOneWidget); + expect(find.text('EARLYLATE'), findsNothing); + expect(finishUseCase.calls, isEmpty); + }, timeout: const Timeout(Duration(seconds: 15))); + + testWidgets( + 'null schedule emission while already notExists navigates home', + (tester) async { + await setLargeTestViewport(tester); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), + GoRoute( + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE')), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(null)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + )..emit(const ScheduleState.notExists()); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilRouteText(tester, 'HOME'); + + expect(find.text('HOME'), findsOneWidget); + expect(find.text('EARLYLATE'), findsNothing); + expect(finishUseCase.calls, isEmpty); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + testWidgets( 'stale notification after schedule end should redirect safely (spec-first)', (tester) async {