Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions lib/presentation/alarm/screens/alarm_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class _AlarmScreenState extends State<AlarmScreen> {
bool _hasShownCompletionDialog = false;
bool _isContinuingAfterCompletion = false;
bool _navigateAfterFinish = false;
bool _didNavigateForNotExistsTransition = false;
int? _pendingEarlyLateSeconds;
bool? _pendingIsLate;
Timer? _uiTickerTimer;
Expand All @@ -48,6 +49,13 @@ class _AlarmScreenState extends State<AlarmScreen> {
_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());
}
Expand Down Expand Up @@ -120,6 +128,7 @@ class _AlarmScreenState extends State<AlarmScreen> {
listener: (context, scheduleState) {
final earlyLateSeconds = _pendingEarlyLateSeconds;
final isLate = _pendingIsLate;
_didNavigateForNotExistsTransition = true;

if (_navigateAfterFinish &&
earlyLateSeconds != null &&
Expand All @@ -145,6 +154,7 @@ class _AlarmScreenState extends State<AlarmScreen> {
final schedule = scheduleState.schedule!;
final preparation = schedule.preparation;
final scheduleChanged = _completionScheduleId != schedule.id;
_didNavigateForNotExistsTransition = false;

if (scheduleChanged) {
_completionScheduleId = schedule.id;
Expand Down Expand Up @@ -181,19 +191,32 @@ class _AlarmScreenState extends State<AlarmScreen> {
});
}

_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(
Expand Down Expand Up @@ -241,10 +264,10 @@ class _AlarmScreenState extends State<AlarmScreen> {
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);
Expand Down Expand Up @@ -315,8 +338,9 @@ class _AlarmScreenState extends State<AlarmScreen> {
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(
Expand Down
36 changes: 36 additions & 0 deletions plans/issue-363-alarm-no-schedule-fallback-plan.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -1143,7 +1143,9 @@ void main() {
const Color(0xFFFF6953).value,
);
expect(
tester.widget<AlarmGraphAnimator>(find.byType(AlarmGraphAnimator)).progress,
tester
.widget<AlarmGraphAnimator>(find.byType(AlarmGraphAnimator))
.progress,
0.0,
);
expect(
Expand Down Expand Up @@ -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 {
Expand Down
Loading