Skip to content

Commit c545ae4

Browse files
authored
Merge pull request #366 from DevKor-github/codex/issue-363-alarm-fallback
[codex] Handle missing schedule alarm fallback
2 parents 577f9ae + 6f0cc0c commit c545ae4

3 files changed

Lines changed: 151 additions & 9 deletions

File tree

lib/presentation/alarm/screens/alarm_screen.dart

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class _AlarmScreenState extends State<AlarmScreen> {
3232
bool _hasShownCompletionDialog = false;
3333
bool _isContinuingAfterCompletion = false;
3434
bool _navigateAfterFinish = false;
35+
bool _didNavigateForNotExistsTransition = false;
3536
int? _pendingEarlyLateSeconds;
3637
bool? _pendingIsLate;
3738
Timer? _uiTickerTimer;
@@ -48,6 +49,13 @@ class _AlarmScreenState extends State<AlarmScreen> {
4849
_isContinuingAfterCompletion = false;
4950
}
5051

52+
void _navigateHomeAfterFrame(BuildContext context) {
53+
WidgetsBinding.instance.addPostFrameCallback((_) {
54+
if (!mounted || !context.mounted) return;
55+
context.go('/home');
56+
});
57+
}
58+
5159
Duration _timeRemainingBeforeLeaving(ScheduleWithPreparationEntity schedule) {
5260
return schedule.timeRemainingBeforeLeavingAt(widget.nowProvider());
5361
}
@@ -120,6 +128,7 @@ class _AlarmScreenState extends State<AlarmScreen> {
120128
listener: (context, scheduleState) {
121129
final earlyLateSeconds = _pendingEarlyLateSeconds;
122130
final isLate = _pendingIsLate;
131+
_didNavigateForNotExistsTransition = true;
123132

124133
if (_navigateAfterFinish &&
125134
earlyLateSeconds != null &&
@@ -145,6 +154,7 @@ class _AlarmScreenState extends State<AlarmScreen> {
145154
final schedule = scheduleState.schedule!;
146155
final preparation = schedule.preparation;
147156
final scheduleChanged = _completionScheduleId != schedule.id;
157+
_didNavigateForNotExistsTransition = false;
148158

149159
if (scheduleChanged) {
150160
_completionScheduleId = schedule.id;
@@ -181,19 +191,32 @@ class _AlarmScreenState extends State<AlarmScreen> {
181191
});
182192
}
183193

184-
_ensureUiTicker(preparation.isAllStepsDone &&
185-
_isContinuingAfterCompletion);
194+
_ensureUiTicker(
195+
preparation.isAllStepsDone && _isContinuingAfterCompletion);
186196
return _buildAlarmScreen(
187197
schedule: schedule,
188198
);
189199
} else if (scheduleState.status == ScheduleStatus.upcoming &&
190200
scheduleState.schedule != null) {
191201
_completionScheduleId = scheduleState.schedule!.id;
202+
_didNavigateForNotExistsTransition = false;
192203
_resetCompletionUiState();
193204
_ensureUiTicker(true);
194205
return _buildEarlyStartReadyScreen(scheduleState.schedule!);
206+
} else if (scheduleState.status == ScheduleStatus.notExists) {
207+
_completionScheduleId = null;
208+
_resetCompletionUiState();
209+
_ensureUiTicker(false);
210+
if (!_navigateAfterFinish && !_didNavigateForNotExistsTransition) {
211+
_navigateHomeAfterFrame(context);
212+
}
213+
return const Scaffold(
214+
backgroundColor: Color(0xff5C79FB),
215+
body: Center(child: CircularProgressIndicator()),
216+
);
195217
} else {
196218
_completionScheduleId = null;
219+
_didNavigateForNotExistsTransition = false;
197220
_resetCompletionUiState();
198221
_ensureUiTicker(false);
199222
return const Scaffold(
@@ -241,10 +264,10 @@ class _AlarmScreenState extends State<AlarmScreen> {
241264
final timerLabel =
242265
isLateContinueMode ? '지각이에요' : preparation.currentStepName;
243266
final displayProgress = isLateContinueMode ? 0.0 : preparation.progress;
244-
final displayRemainingSeconds = preparation.isAllStepsDone &&
245-
_isContinuingAfterCompletion
246-
? timeRemainingBeforeLeaving.inSeconds.abs()
247-
: preparation.currentStepRemainingTime.inSeconds;
267+
final displayRemainingSeconds =
268+
preparation.isAllStepsDone && _isContinuingAfterCompletion
269+
? timeRemainingBeforeLeaving.inSeconds.abs()
270+
: preparation.currentStepRemainingTime.inSeconds;
248271

249272
if (!(preparation.isAllStepsDone && _isContinuingAfterCompletion)) {
250273
_ensureUiTicker(false);
@@ -315,8 +338,9 @@ class _AlarmScreenState extends State<AlarmScreen> {
315338
Widget _buildEarlyStartReadyScreen(ScheduleWithPreparationEntity schedule) {
316339
final l10n = AppLocalizations.of(context)!;
317340
final theme = Theme.of(context);
318-
final remaining =
319-
schedule.preparationStartTime.difference(widget.nowProvider()).inSeconds;
341+
final remaining = schedule.preparationStartTime
342+
.difference(widget.nowProvider())
343+
.inSeconds;
320344
final clampedRemaining = remaining.isNegative ? 0 : remaining;
321345

322346
return Scaffold(
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Issue 363 Alarm No-Schedule Fallback Plan
2+
3+
## Goal
4+
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.
5+
6+
Issue: https://github.com/DevKor-github/OnTime-front/issues/363
7+
8+
## Context
9+
- The issue covers stale notification, deleted schedule, and already-ended schedule entry paths.
10+
- `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`.
11+
- When the alarm route is built with an already-`notExists` state, no listener event fires and the `BlocBuilder` falls through to the loading scaffold.
12+
- 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.
13+
- Existing alarm widget coverage lives in `test/presentation/alarm/screens/preparation_flow_widget_test.dart`.
14+
15+
## Decisions
16+
- Treat an already-`notExists` state on alarm route entry as terminal for `/alarmScreen`.
17+
- Trigger the fallback with a post-frame callback, because navigation should not happen synchronously during build.
18+
- Keep the existing transition listener for active schedule flows that later become `notExists`.
19+
- Preserve `_navigateAfterFinish` handling so manual or dialog finish still routes to `/earlyLate`.
20+
- Scope the implementation to `AlarmScreen` and alarm widget tests; no bloc behavior change is needed.
21+
22+
## Steps
23+
1. In `AlarmScreen`, add a helper such as `_navigateHomeAfterFrameIfMounted(BuildContext context)` to centralize safe post-frame home navigation.
24+
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.
25+
3. Leave the current `BlocListener` in place for `ongoing`, `started`, or `upcoming` states that later emit `notExists`.
26+
4. Make sure the loading scaffold remains only for genuinely unresolved statuses, especially `ScheduleStatus.initial`.
27+
5. Add a widget test that seeds the alarm bloc with `const ScheduleState.notExists()` before pumping `/alarmScreen`, then expects `/home`.
28+
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.
29+
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.
30+
31+
## Validation
32+
- `flutter test test/presentation/alarm/screens/preparation_flow_widget_test.dart`
33+
- `flutter analyze`
34+
35+
## Open Questions
36+
- None.

test/presentation/alarm/screens/preparation_flow_widget_test.dart

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1143,7 +1143,9 @@ void main() {
11431143
const Color(0xFFFF6953).value,
11441144
);
11451145
expect(
1146-
tester.widget<AlarmGraphAnimator>(find.byType(AlarmGraphAnimator)).progress,
1146+
tester
1147+
.widget<AlarmGraphAnimator>(find.byType(AlarmGraphAnimator))
1148+
.progress,
11471149
0.0,
11481150
);
11491151
expect(
@@ -1225,6 +1227,86 @@ void main() {
12251227
expect(finishUseCase.calls.length, 1);
12261228
}, timeout: const Timeout(Duration(seconds: 15)));
12271229

1230+
testWidgets('already missing schedule on alarm entry navigates home',
1231+
(tester) async {
1232+
await setLargeTestViewport(tester);
1233+
1234+
final router = GoRouter(
1235+
initialLocation: '/alarmScreen',
1236+
routes: [
1237+
GoRoute(path: '/home', builder: (_, __) => const Text('HOME')),
1238+
GoRoute(
1239+
path: '/alarmScreen', builder: (_, __) => const AlarmScreen()),
1240+
GoRoute(
1241+
path: '/earlyLate', builder: (_, __) => const Text('EARLYLATE')),
1242+
],
1243+
);
1244+
1245+
final earlyBundle = createEarlyStartUseCaseBundle();
1246+
final alarmBloc = ScheduleBloc.test(
1247+
StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()),
1248+
navigationService,
1249+
NoopSaveTimedPreparationUseCase(),
1250+
StubGetTimedPreparationSnapshotUseCase({}),
1251+
NoopClearTimedPreparationUseCase(),
1252+
finishUseCase,
1253+
markEarlyStartSessionUseCase: earlyBundle.markUseCase,
1254+
getEarlyStartSessionUseCase: earlyBundle.getUseCase,
1255+
clearEarlyStartSessionUseCase: earlyBundle.clearUseCase,
1256+
nowProvider: () => now,
1257+
)..emit(const ScheduleState.notExists());
1258+
addTearDown(alarmBloc.close);
1259+
1260+
await pumpWithRouter(tester, bloc: alarmBloc, router: router);
1261+
await pumpUntilRouteText(tester, 'HOME');
1262+
1263+
expect(find.text('HOME'), findsOneWidget);
1264+
expect(find.text('EARLYLATE'), findsNothing);
1265+
expect(finishUseCase.calls, isEmpty);
1266+
}, timeout: const Timeout(Duration(seconds: 15)));
1267+
1268+
testWidgets(
1269+
'null schedule emission while already notExists navigates home',
1270+
(tester) async {
1271+
await setLargeTestViewport(tester);
1272+
1273+
final router = GoRouter(
1274+
initialLocation: '/alarmScreen',
1275+
routes: [
1276+
GoRoute(path: '/home', builder: (_, __) => const Text('HOME')),
1277+
GoRoute(
1278+
path: '/alarmScreen', builder: (_, __) => const AlarmScreen()),
1279+
GoRoute(
1280+
path: '/earlyLate',
1281+
builder: (_, __) => const Text('EARLYLATE')),
1282+
],
1283+
);
1284+
1285+
final earlyBundle = createEarlyStartUseCaseBundle();
1286+
final alarmBloc = ScheduleBloc.test(
1287+
StubGetNearestUpcomingScheduleUseCase(() => Stream.value(null)),
1288+
navigationService,
1289+
NoopSaveTimedPreparationUseCase(),
1290+
StubGetTimedPreparationSnapshotUseCase({}),
1291+
NoopClearTimedPreparationUseCase(),
1292+
finishUseCase,
1293+
markEarlyStartSessionUseCase: earlyBundle.markUseCase,
1294+
getEarlyStartSessionUseCase: earlyBundle.getUseCase,
1295+
clearEarlyStartSessionUseCase: earlyBundle.clearUseCase,
1296+
nowProvider: () => now,
1297+
)..emit(const ScheduleState.notExists());
1298+
addTearDown(alarmBloc.close);
1299+
1300+
await pumpWithRouter(tester, bloc: alarmBloc, router: router);
1301+
await pumpUntilRouteText(tester, 'HOME');
1302+
1303+
expect(find.text('HOME'), findsOneWidget);
1304+
expect(find.text('EARLYLATE'), findsNothing);
1305+
expect(finishUseCase.calls, isEmpty);
1306+
},
1307+
timeout: const Timeout(Duration(seconds: 15)),
1308+
);
1309+
12281310
testWidgets(
12291311
'stale notification after schedule end should redirect safely (spec-first)',
12301312
(tester) async {

0 commit comments

Comments
 (0)