From 6a9ea14f540770833fd6530f09afb84d529d7837 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 6 May 2026 00:37:43 +0900 Subject: [PATCH] feat: refine startup flow --- lib/main.dart | 20 --- lib/presentation/app/bloc/auth/auth_bloc.dart | 12 +- .../app/bloc/auth/auth_state.dart | 7 + .../app/cubit/notification_gate_cubit.dart | 62 ++++++++ .../app/cubit/notification_gate_state.dart | 35 +++++ lib/presentation/app/screens/app.dart | 11 +- .../screens/notification_allow_screen.dart | 25 +++- .../onboarding/screens/onboarding_screen.dart | 35 ++++- .../screens/onboarding_start_screen.dart | 2 +- lib/presentation/shared/router/go_router.dart | 66 +++++---- .../startup/screens/startup_screen.dart | 22 +++ plans/startup-flow-refinement.md | 137 ++++++++++++++++++ 12 files changed, 372 insertions(+), 62 deletions(-) create mode 100644 lib/presentation/app/cubit/notification_gate_cubit.dart create mode 100644 lib/presentation/app/cubit/notification_gate_state.dart create mode 100644 lib/presentation/startup/screens/startup_screen.dart create mode 100644 plans/startup-flow-refinement.md diff --git a/lib/main.dart b/lib/main.dart index 3407f18c..6c524fae 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,9 @@ -import 'dart:async'; - import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:on_time_front/core/constants/environment_variable.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/device_info_service/shared.dart'; -import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/firebase_options.dart'; import 'package:on_time_front/presentation/app/screens/app.dart'; @@ -21,22 +17,6 @@ void main() async { ); debugPrint('[FCM Main] Firebase 초기화 완료'); - final permission = - await NotificationService.instance.checkNotificationPermission(); - debugPrint('[FCM Main] Notification Permission: $permission'); - - if (permission == AuthorizationStatus.authorized) { - unawaited( - NotificationService.instance.initialize().catchError( - (Object error, StackTrace stackTrace) { - debugPrint('[FCM Main] NotificationService initialize 실패: $error'); - }, - ), - ); - } else { - debugPrint('[FCM Main] 알림 권한이 없어 NotificationService를 초기화하지 않습니다'); - } - debugPrint(DeviceInfoService.isInStandaloneMode.toString()); runApp(App()); } diff --git a/lib/presentation/app/bloc/auth/auth_bloc.dart b/lib/presentation/app/bloc/auth/auth_bloc.dart index af8a2cdd..24b3f5e0 100644 --- a/lib/presentation/app/bloc/auth/auth_bloc.dart +++ b/lib/presentation/app/bloc/auth/auth_bloc.dart @@ -16,7 +16,7 @@ part 'auth_state.dart'; class AuthBloc extends Bloc { AuthBloc(this._streamUserUseCase, this._signOutUseCase, this._loadUserUseCase, this._scheduleBloc) - : super(AuthState(user: const UserEntity.empty())) { + : super(const AuthState.loading()) { on(_appUserSubscriptionRequested); on(_appLogoutPressed); } @@ -30,8 +30,14 @@ class AuthBloc extends Bloc { Future _appUserSubscriptionRequested( AuthUserSubscriptionRequested event, Emitter emit, - ) { - _loadUserUseCase(); + ) async { + try { + await _loadUserUseCase(); + } catch (error, stackTrace) { + addError(error, stackTrace); + emit(AuthState(user: const UserEntity.empty())); + } + return emit.onEach( _streamUserUseCase.call(), onData: (user) async { diff --git a/lib/presentation/app/bloc/auth/auth_state.dart b/lib/presentation/app/bloc/auth/auth_state.dart index 842e92d3..82f3f878 100644 --- a/lib/presentation/app/bloc/auth/auth_state.dart +++ b/lib/presentation/app/bloc/auth/auth_state.dart @@ -1,6 +1,7 @@ part of 'auth_bloc.dart'; enum AuthStatus { + loading, authenticated, unauthenticated, onboardingNotCompleted, @@ -18,6 +19,12 @@ class AuthState extends Equatable { user: user, ); + const AuthState.loading() + : this._( + status: AuthStatus.loading, + user: const UserEntity.empty(), + ); + const AuthState._({ required this.status, this.user = const UserEntity.empty(), diff --git a/lib/presentation/app/cubit/notification_gate_cubit.dart b/lib/presentation/app/cubit/notification_gate_cubit.dart new file mode 100644 index 00000000..e4259126 --- /dev/null +++ b/lib/presentation/app/cubit/notification_gate_cubit.dart @@ -0,0 +1,62 @@ +import 'package:equatable/equatable.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'notification_gate_state.dart'; + +class NotificationGateCubit extends Cubit { + NotificationGateCubit({ + NotificationService? notificationService, + }) : _notificationService = + notificationService ?? NotificationService.instance, + super(const NotificationGateState.initial()) { + refreshPermission(); + } + + static const String _dismissedKey = 'notification_prompt_dismissed'; + + final NotificationService _notificationService; + + Future refreshPermission() async { + final prefs = await SharedPreferences.getInstance(); + final isDismissed = prefs.getBool(_dismissedKey) ?? false; + if (isDismissed) { + emit(const NotificationGateState.dismissed()); + return; + } + + final permission = await _notificationService.checkNotificationPermission(); + if (permission == AuthorizationStatus.authorized) { + await _initializeNotifications(); + emit(const NotificationGateState.allowed()); + return; + } + + emit(const NotificationGateState.required()); + } + + Future markPermissionAllowed() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_dismissedKey); + await _initializeNotifications(); + emit(const NotificationGateState.allowed()); + } + + Future dismissPrompt() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_dismissedKey, true); + emit(const NotificationGateState.dismissed()); + } + + Future _initializeNotifications() async { + try { + await _notificationService.initialize(); + } catch (error, stackTrace) { + debugPrint('[NotificationGate] initialize failed: $error'); + debugPrintStack(stackTrace: stackTrace); + } + } +} diff --git a/lib/presentation/app/cubit/notification_gate_state.dart b/lib/presentation/app/cubit/notification_gate_state.dart new file mode 100644 index 00000000..0c99b9c1 --- /dev/null +++ b/lib/presentation/app/cubit/notification_gate_state.dart @@ -0,0 +1,35 @@ +part of 'notification_gate_cubit.dart'; + +enum NotificationGateStatus { + initial, + allowed, + required, + dismissed, +} + +class NotificationGateState extends Equatable { + const NotificationGateState._({ + required this.status, + }); + + const NotificationGateState.initial() + : this._(status: NotificationGateStatus.initial); + + const NotificationGateState.allowed() + : this._(status: NotificationGateStatus.allowed); + + const NotificationGateState.required() + : this._(status: NotificationGateStatus.required); + + const NotificationGateState.dismissed() + : this._(status: NotificationGateStatus.dismissed); + + final NotificationGateStatus status; + + bool get isResolved => status != NotificationGateStatus.initial; + + bool get shouldPrompt => status == NotificationGateStatus.required; + + @override + List get props => [status]; +} diff --git a/lib/presentation/app/screens/app.dart b/lib/presentation/app/screens/app.dart index 22c235ee..6eddbf58 100644 --- a/lib/presentation/app/screens/app.dart +++ b/lib/presentation/app/screens/app.dart @@ -4,6 +4,7 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; import 'package:on_time_front/presentation/shared/router/go_router.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; @@ -21,6 +22,9 @@ class App extends StatelessWidget { BlocProvider( create: (context) => getIt.get(), ), + BlocProvider( + create: (context) => NotificationGateCubit(), + ), ], child: const AppView(), ); @@ -44,8 +48,11 @@ class _AppRouterView extends StatefulWidget { } class _AppRouterViewState extends State<_AppRouterView> { - late final _router = - goRouterConfig(context.read(), context.read()); + late final _router = goRouterConfig( + context.read(), + context.read(), + context.read(), + ); @override Widget build(BuildContext context) { diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index 4794e5a5..ce68b905 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -1,9 +1,11 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; @@ -69,7 +71,9 @@ class _Buttons extends StatelessWidget { ), ), GestureDetector( - onTap: () { + onTap: () async { + await context.read().dismissPrompt(); + if (!context.mounted) return; context.go('/home'); }, child: SizedBox( @@ -160,7 +164,7 @@ Future _handleNotificationPermission(BuildContext context) async { if (!context.mounted) return; if (currentStatus == AuthorizationStatus.authorized) { - await notificationService.initialize(); + await context.read().markPermissionAllowed(); if (context.mounted) { context.go('/home'); } @@ -168,6 +172,11 @@ Future _handleNotificationPermission(BuildContext context) async { final shouldOpenSettings = await _showGoToSettingsDialog(context); if (shouldOpenSettings == true) { await notificationService.openNotificationSettings(); + } else if (context.mounted) { + await context.read().dismissPrompt(); + if (context.mounted) { + context.go('/home'); + } } } else if (currentStatus == AuthorizationStatus.notDetermined) { final newStatus = await notificationService.requestPermission(); @@ -175,7 +184,7 @@ Future _handleNotificationPermission(BuildContext context) async { if (!context.mounted) return; if (newStatus == AuthorizationStatus.authorized) { - await notificationService.initialize(); + await context.read().markPermissionAllowed(); if (context.mounted) { context.go('/home'); } @@ -183,12 +192,22 @@ Future _handleNotificationPermission(BuildContext context) async { final shouldOpenSettings = await _showGoToSettingsDialog(context); if (shouldOpenSettings == true) { await notificationService.openNotificationSettings(); + } else if (context.mounted) { + await context.read().dismissPrompt(); + if (context.mounted) { + context.go('/home'); + } } } } else { final shouldOpenSettings = await _showGoToSettingsDialog(context); if (shouldOpenSettings == true) { await notificationService.openNotificationSettings(); + } else if (context.mounted) { + await context.read().dismissPrompt(); + if (context.mounted) { + context.go('/home'); + } } } } diff --git a/lib/presentation/onboarding/screens/onboarding_screen.dart b/lib/presentation/onboarding/screens/onboarding_screen.dart index 2b4db4f7..46d773da 100644 --- a/lib/presentation/onboarding/screens/onboarding_screen.dart +++ b/lib/presentation/onboarding/screens/onboarding_screen.dart @@ -41,6 +41,7 @@ class _OnboardingFormState extends State<_OnboardingForm> with TickerProviderStateMixin { late PageController _pageViewController; late TabController _tabController; + bool _isSubmitting = false; final List _pageCubitTypes = [ PreparationNameCubit, PreparationOrderCubit, @@ -114,11 +115,19 @@ class _OnboardingFormState extends State<_OnboardingForm> height: 58, width: double.infinity, child: ElevatedButton( - onPressed: context.select( - (OnboardingCubit cubit) => cubit.state.isValid) + onPressed: !_isSubmitting && + context.select((OnboardingCubit cubit) => + cubit.state.isValid) ? () => _onNextPageButtonClicked(context) : null, - child: Text(AppLocalizations.of(context)!.next), + child: _isSubmitting + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(AppLocalizations.of(context)!.next), )), ], ); @@ -146,7 +155,25 @@ class _OnboardingFormState extends State<_OnboardingForm> if (_tabController.index < _tabController.length - 1) { _updateCurrentPageIndex(_tabController.index + 1); } else { - return await context.read().onboardingFormSubmitted(); + setState(() { + _isSubmitting = true; + }); + try { + await context.read().onboardingFormSubmitted(); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.error), + ), + ); + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } } } diff --git a/lib/presentation/onboarding/screens/onboarding_start_screen.dart b/lib/presentation/onboarding/screens/onboarding_start_screen.dart index a7aa3444..9d62a79e 100644 --- a/lib/presentation/onboarding/screens/onboarding_start_screen.dart +++ b/lib/presentation/onboarding/screens/onboarding_start_screen.dart @@ -84,7 +84,7 @@ class _OnboardingStartButton extends StatelessWidget { width: double.infinity, child: ElevatedButton( onPressed: () { - context.push('/onboarding'); + context.go('/onboarding'); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index 7bb8f3d3..46e58448 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -1,14 +1,13 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; -import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/presentation/alarm/screens/alarm_screen.dart'; import 'package:on_time_front/presentation/alarm/screens/schedule_start_screen.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; import 'package:on_time_front/presentation/early_late/screens/early_late_screen.dart'; import 'package:on_time_front/presentation/calendar/screens/calendar_screen.dart'; import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; @@ -24,48 +23,57 @@ import 'package:on_time_front/presentation/schedule_create/screens/schedule_crea import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; import 'package:on_time_front/presentation/shared/components/bottom_nav_bar_scaffold.dart'; import 'package:on_time_front/presentation/shared/utils/stream_to_listenable.dart'; +import 'package:on_time_front/presentation/startup/screens/startup_screen.dart'; final GlobalKey navigatorKey = GlobalKey(); -GoRouter goRouterConfig(AuthBloc authBloc, ScheduleBloc scheduleBloc) { +GoRouter goRouterConfig( + AuthBloc authBloc, + ScheduleBloc scheduleBloc, + NotificationGateCubit notificationGateCubit, +) { return GoRouter( - refreshListenable: - StreamToListenable([scheduleBloc.stream, authBloc.stream]), + refreshListenable: StreamToListenable([ + scheduleBloc.stream, + authBloc.stream, + notificationGateCubit.stream, + ]), navigatorKey: getIt.get().navigatorKey, - redirect: (BuildContext context, GoRouterState state) async { + redirect: (BuildContext context, GoRouterState state) { final authStatus = authBloc.state.status; - final scheduleStatus = scheduleBloc.state.status; - final bool onSignInScreen = state.fullPath == '/signIn'; - final bool onOnbaordingStartScreen = - state.fullPath == '/onboarding/start'; - final bool onOnboardingScreen = state.fullPath == '/onboarding'; + final notificationGateStatus = notificationGateCubit.state.status; + final path = state.uri.path; + final isStartupRoute = path == '/startup'; + final isPublicRoute = isStartupRoute || path == '/signIn'; + final isOnboardingRoute = + path == '/onboarding' || path == '/onboarding/start'; + final isNotificationRoute = path == '/allowNotification'; + final isTransientRoute = + isPublicRoute || isOnboardingRoute || isNotificationRoute; switch (authStatus) { + case AuthStatus.loading: + return isStartupRoute ? null : '/startup'; case AuthStatus.unauthenticated: - return '/signIn'; + return path == '/signIn' ? null : '/signIn'; case AuthStatus.authenticated: - if (onSignInScreen || onOnboardingScreen || onOnbaordingStartScreen) { - final permission = await NotificationService.instance - .checkNotificationPermission(); - if (permission != AuthorizationStatus.authorized) { - return '/allowNotification'; - } - return '/home'; - } else if (scheduleStatus == ScheduleStatus.started) { - return null; - } else { - return null; + if (!notificationGateCubit.state.isResolved) { + return isStartupRoute ? null : '/startup'; } - case AuthStatus.onboardingNotCompleted: - if (onOnboardingScreen || onOnbaordingStartScreen) { - return null; - } else { - return '/onboarding/start'; + if (notificationGateStatus == NotificationGateStatus.required) { + return isNotificationRoute ? null : '/allowNotification'; } + return isTransientRoute ? '/home' : null; + case AuthStatus.onboardingNotCompleted: + return isOnboardingRoute ? null : '/onboarding/start'; } }, - initialLocation: '/home', + initialLocation: '/startup', routes: [ + GoRoute( + path: '/startup', + builder: (context, state) => const StartupScreen(), + ), GoRoute( path: '/allowNotification', builder: (context, state) { diff --git a/lib/presentation/startup/screens/startup_screen.dart b/lib/presentation/startup/screens/startup_screen.dart new file mode 100644 index 00000000..a6551385 --- /dev/null +++ b/lib/presentation/startup/screens/startup_screen.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class StartupScreen extends StatelessWidget { + const StartupScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colorScheme.primary, + body: Center( + child: Image.asset( + 'logo.png', + package: 'assets', + width: 167, + color: colorScheme.onPrimary, + ), + ), + ); + } +} diff --git a/plans/startup-flow-refinement.md b/plans/startup-flow-refinement.md new file mode 100644 index 00000000..c94fd804 --- /dev/null +++ b/plans/startup-flow-refinement.md @@ -0,0 +1,137 @@ +# Startup Flow Refinement Plan + +## Current Flow + +App launch currently follows this path: + +1. `main.dart` blocks startup on date formatting, DI setup, Firebase initialization, and notification permission lookup. +2. `App` creates `AuthBloc` and immediately dispatches `AuthUserSubscriptionRequested`. +3. `GoRouter` starts at `/home`. +4. Because `AuthBloc` is seeded with `UserEntity.empty()`, the first router redirect often treats the user as unauthenticated and sends them to `/signIn`. +5. `AuthBloc` calls `LoadUserUseCase`, then listens to `userStream`. When a real user arrives, router refresh redirects again based on auth/onboarding status. +6. Authenticated users who enter from sign-in or onboarding routes get a notification permission check inside the router redirect, then may be sent to `/allowNotification` or `/home`. +7. Users with incomplete onboarding are sent to `/onboarding/start`, then manually push `/onboarding`. + +## Why It Feels Unnatural + +The app has no explicit startup resolution state. A returning signed-in user can briefly be routed as signed out because the initial auth state is `unauthenticated` before persisted user loading finishes. + +The initial route is optimistic (`/home`), but the first meaningful state is pessimistic (`unauthenticated`). That can cause visible route churn: home target, sign-in redirect, then home/onboarding/notification redirect. + +Notification permission is treated as a router-side auth redirect only after sign-in/onboarding routes. That means a user can reach `/home` directly while permission is missing, but after certain transitions the same state routes to `/allowNotification`. The rule is inconsistent. + +The router performs async side-effect-like work by checking notification permission during redirect. Redirects should ideally be fast, deterministic mappings from app state to route. + +Onboarding has two separate entry routes (`/onboarding/start` and `/onboarding`) and the start button uses `push`. That leaves a back-stack relationship between the welcome screen and form, while the auth guard also forces users back to the start screen from other routes. + +After onboarding submit, navigation depends on `OnboardUseCase` calling `getUser()`, the repository stream emitting a completed user, `AuthBloc` updating, and router refresh firing. There is no explicit success/loading/error state in the onboarding UI, so completion can feel delayed or ambiguous. + +Schedule startup can also affect launch later: once authenticated, `AuthBloc` starts schedule subscription, and `ScheduleBloc` can imperatively push `/scheduleStart` when preparation begins. That is useful for alarms, but it is separate from router guards and should remain isolated from the auth/onboarding first-run flow. + +## Target Flow + +The startup experience should resolve app state once, then move forward: + +1. Launch shows a stable splash/loading surface while startup state is unknown. +2. Auth state resolves to one of: unauthenticated, onboarding required, notification prompt needed, ready. +3. Router maps that state to exactly one top-level destination. +4. User actions use `go` when crossing app phases, not `push`. +5. Notification prompt is optional and consistent: either always shown once after onboarding/sign-in when needed, or handled as a non-blocking in-home prompt. +6. Onboarding submit shows progress and errors locally, then transitions intentionally. + +## Proposed Implementation + +### 1. Add An Explicit Auth Bootstrap State + +Add an `AuthStatus.initial` or `AuthStatus.loading` state. Start `AuthBloc` in that state instead of deriving unauthenticated from `UserEntity.empty()` before `LoadUserUseCase` completes. + +In `AuthUserSubscriptionRequested`, await or track the initial load result enough to avoid routing to `/signIn` before persisted token/user resolution has completed. If `getUser()` fails with a non-auth error, expose an auth error state or keep the startup screen with retry. + +Expected result: returning users no longer see sign-in as a transient launch screen. + +### 2. Introduce A Startup Route + +Change `initialLocation` from `/home` to a neutral route such as `/startup` or `/splash`. + +Add a lightweight startup screen using the app logo/character and no navigation controls. The router should keep users there while auth status is loading. + +Expected result: launch has one stable first screen instead of route churn. + +### 3. Make Redirects Pure And Complete + +Move notification permission knowledge into app state, for example an `AppGateCubit`, `NotificationPermissionCubit`, or an expanded auth/startup gate state. The router should read a cached status rather than awaiting `NotificationService.instance.checkNotificationPermission()` inside `redirect`. + +Define route groups: + +- Public: `/startup`, `/signIn` +- Onboarding: `/onboarding/start`, `/onboarding` +- Notification gate: `/allowNotification` +- Authenticated app: `/home`, `/myPage`, `/calendar`, schedule routes, settings routes + +Then make redirect rules exhaustive: + +- Loading -> `/startup` +- Unauthenticated -> `/signIn`, unless already public +- Onboarding required -> `/onboarding/start` or current onboarding route +- Authenticated + notification required -> `/allowNotification`, unless already there +- Ready -> `/home` when currently on startup/sign-in/onboarding/allow-notification +- Ready + already on an authenticated deep route -> stay there + +Expected result: direct `/home`, post-login, and post-onboarding all behave consistently. + +### 4. Decide Notification Gate Product Behavior + +Choose one of two product behaviors before implementation: + +- Blocking soft gate: show `/allowNotification` once after sign-in/onboarding if permission is not authorized, with `Do it later` continuing to home. +- Non-blocking prompt: send users to home and show notification education there or from My Page. + +If keeping `/allowNotification`, persist a local `notificationPromptDismissed` flag so users who tap `Do it later` are not routed back to the prompt on every auth transition. + +Expected result: notification permission feels like part of onboarding, not an unpredictable detour. + +### 5. Normalize Phase Transitions + +Use `context.go('/onboarding')` from onboarding start instead of `push`, because moving from intro to form is a phase transition within the guarded onboarding flow. + +After onboarding submit, show a submitting state on the button, prevent double taps, and handle failure with an inline/dialog error. On success, either update auth/startup state explicitly or ensure the user stream update is awaited before the router transition. + +Expected result: onboarding completion feels deliberate and recoverable. + +### 6. Keep Schedule Auto-Navigation Separate + +Do not let schedule status influence auth/startup redirects except for preserving current schedule routes when already authenticated. + +Longer term, consider moving the imperative `/scheduleStart` push behind a coordinator that checks the current route, so launch-time auth routing and alarm routing do not race each other. + +Expected result: time-sensitive schedule behavior remains intact without making app launch harder to reason about. + +## Suggested Work Order + +1. Add `AuthStatus.loading` and adjust `AuthBloc` initial loading behavior. +2. Add `/startup` screen and set it as `initialLocation`. +3. Refactor router redirect into named helpers with route-group predicates. +4. Move notification permission check out of `redirect`; add a small cached gate state. +5. Add notification dismissed persistence if `/allowNotification` remains a soft gate. +6. Update onboarding navigation from `push` to `go`, add submit loading/error state. +7. Add tests for startup redirects and onboarding completion. + +## Test Plan + +Add focused router/auth tests for: + +- Cold launch with persisted authenticated user goes `startup -> home` without visiting sign-in. +- Cold launch with no valid token goes `startup -> signIn`. +- New social user goes `signIn -> onboarding/start -> onboarding -> allowNotification or home`. +- Completed user with notification already authorized goes directly to `/home`. +- Completed user with notification not determined goes to the chosen notification experience. +- Tapping `Do it later` reaches `/home` and does not immediately re-route back. +- Deep link to authenticated route is preserved after auth loading when allowed. + +Run: + +```sh +flutter analyze +flutter test +``` +