diff --git a/README.md b/README.md index ed47538..9220b43 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,12 @@ Offer users control over their app experience: * **Feed Display:** Customize how headlines are presented. > **Your Advantage:** Deliver a premium, adaptable user experience that caters to individual needs without writing any code. 🔧 +#### 📡 **Backend-Driven Behavior** +The app is built to respond to commands from your backend API, allowing for dynamic control over the user experience: +* **Maintenance Mode:** Displays a full-screen "kill switch" page when the backend signals that the service is temporarily unavailable. +* **Forced Updates:** Shows a non-dismissible "Update Required" screen when a new version is mandatory, guiding users to the app store. +> **Your Advantage:** The client-side logic to handle critical operational states is already implemented. Your app can gracefully manage server downtime and enforce version updates without you needing to code these complex, full-screen takeover flows. 🛠️ + #### 📱 **Adaptive UI for All Screens** Built with `flutter_adaptive_scaffold`, the app offers responsive navigation and layouts that look great on both phones and tablets. > **Your Advantage:** Deliver a consistent and optimized UX across a wide range of devices effortlessly. ↔️ diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 52bc974..44c77d2 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -59,7 +59,6 @@ class AppBloc extends Bloc { on(_onFontFamilyChanged); on(_onAppTextScaleFactorChanged); on(_onAppFontWeightChanged); - on(_onAppOpened); // Listen directly to the auth state changes stream _userSubscription = _authenticationRepository.authStateChanges.listen( @@ -412,67 +411,91 @@ class AppBloc extends Bloc { return; } - // Avoid refetching if already loaded for the current user session, unless explicitly trying to recover from a failed state. - if (state.remoteConfig != null && - state.status != AppStatus.configFetchFailed) { + // For background checks, we don't want to show a loading screen. + // Only for the initial fetch should we set the status to configFetching. + if (!event.isBackgroundCheck) { print( - '[AppBloc] AppConfig already loaded for user ${state.user?.id} and not in a failed state. Skipping fetch.', + '[AppBloc] Initial config fetch. Setting status to configFetching.', ); - return; + emit( + state.copyWith( + status: AppStatus.configFetching, + ), + ); + } else { + print('[AppBloc] Background config fetch. Proceeding silently.'); } - print( - '[AppBloc] Attempting to fetch AppConfig for user: ${state.user!.id}...', - ); - emit( - state.copyWith( - status: AppStatus.configFetching, - remoteConfig: null, - clearAppConfig: true, - ), - ); - try { final remoteConfig = await _appConfigRepository.read(id: kRemoteConfigId); print( '[AppBloc] Remote Config fetched successfully. ID: ${remoteConfig.id} for user: ${state.user!.id}', ); - // Determine the correct status based on the existing user's role. - // This ensures that successfully fetching config doesn't revert auth status to 'initial'. - final newStatusBasedOnUser = - state.user!.appRole == AppUserRole.standardUser - ? AppStatus.authenticated - : AppStatus.anonymous; - emit( - state.copyWith( - remoteConfig: remoteConfig, - status: newStatusBasedOnUser, - ), - ); + // --- CRITICAL STATUS EVALUATION --- + // For both initial and background checks, if a critical status is found, + // we must update the app status immediately to lock the UI. + + // 1. Check for Maintenance Mode. This has the highest priority. + if (remoteConfig.appStatus.isUnderMaintenance) { + emit( + state.copyWith( + status: AppStatus.underMaintenance, + remoteConfig: remoteConfig, + ), + ); + return; + } + + // 2. Check for a Required Update. + // TODO(fulleni): Compare with actual app version from package_info_plus. + if (remoteConfig.appStatus.isLatestVersionOnly) { + emit( + state.copyWith( + status: AppStatus.updateRequired, + remoteConfig: remoteConfig, + ), + ); + return; + } + + // --- POST-CHECK STATE RESOLUTION --- + // If no critical status was found, we resolve the final state. + + // For an initial fetch, we transition from configFetching to the correct + // authenticated/anonymous state. + if (!event.isBackgroundCheck) { + final finalStatus = state.user!.appRole == AppUserRole.standardUser + ? AppStatus.authenticated + : AppStatus.anonymous; + emit(state.copyWith(remoteConfig: remoteConfig, status: finalStatus)); + } else { + // For a background check, the status is already correct (e.g., authenticated). + // We just need to update the remoteConfig in the state silently. + // The status does not need to change, preventing a disruptive UI rebuild. + emit(state.copyWith(remoteConfig: remoteConfig)); + } } on HttpException catch (e) { print( '[AppBloc] Failed to fetch AppConfig (HttpException) for user ${state.user?.id}: ${e.runtimeType} - ${e.message}', ); - emit( - state.copyWith( - status: AppStatus.configFetchFailed, - remoteConfig: null, - clearAppConfig: true, - ), - ); + // Only show a failure screen on an initial fetch. + // For background checks, we fail silently to avoid disruption. + if (!event.isBackgroundCheck) { + emit(state.copyWith(status: AppStatus.configFetchFailed)); + } else { + print('[AppBloc] Silent failure on background config fetch.'); + } } catch (e, s) { print( '[AppBloc] Unexpected error fetching AppConfig for user ${state.user?.id}: $e', ); print('[AppBloc] Stacktrace: $s'); - emit( - state.copyWith( - status: AppStatus.configFetchFailed, - remoteConfig: null, - clearAppConfig: true, - ), - ); + if (!event.isBackgroundCheck) { + emit(state.copyWith(status: AppStatus.configFetchFailed)); + } else { + print('[AppBloc] Silent failure on background config fetch.'); + } } } @@ -524,24 +547,4 @@ class AppBloc extends Bloc { ); } } - - Future _onAppOpened(AppOpened event, Emitter emit) async { - if (state.remoteConfig == null) { - return; - } - - final appStatus = state.remoteConfig!.appStatus; - - if (appStatus.isUnderMaintenance) { - emit(state.copyWith(status: AppStatus.underMaintenance)); - return; - } - - // TODO(fulleni): Get the current app version from a package like - // package_info_plus and compare it with appStatus.latestAppVersion. - if (appStatus.isLatestVersionOnly) { - emit(state.copyWith(status: AppStatus.updateRequired)); - return; - } - } } diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 33fd3ee..306c23f 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -7,6 +7,7 @@ abstract class AppEvent extends Equatable { List get props => []; } +/// Dispatched when the authentication state changes (e.g., user logs in/out). class AppUserChanged extends AppEvent { const AppUserChanged(this.user); @@ -16,122 +17,81 @@ class AppUserChanged extends AppEvent { List get props => [user]; } -/// {@template app_settings_refreshed} -/// Internal event to trigger reloading of settings within AppBloc. -/// Added when user changes or upon explicit request. -/// {@endtemplate} +/// Dispatched to request a refresh of the user's application settings. class AppSettingsRefreshed extends AppEvent { - /// {@macro app_settings_refreshed} const AppSettingsRefreshed(); } -/// {@template app_logout_requested} -/// Event to request user logout. -/// {@endtemplate} +/// Dispatched to fetch the remote application configuration. +class AppConfigFetchRequested extends AppEvent { + const AppConfigFetchRequested({this.isBackgroundCheck = false}); + + /// Whether this fetch is a silent background check. + /// + /// If `true`, the BLoC will not enter a visible loading state. + /// If `false` (default), it's treated as an initial fetch that shows a + /// loading UI. + final bool isBackgroundCheck; + + @override + List get props => [isBackgroundCheck]; +} + +/// Dispatched when the user logs out. class AppLogoutRequested extends AppEvent { - /// {@macro app_logout_requested} const AppLogoutRequested(); } -/// {@template app_theme_mode_changed} -/// Event to change the application's theme mode. -/// {@endtemplate} +/// Dispatched when the theme mode (light/dark/system) changes. class AppThemeModeChanged extends AppEvent { - /// {@macro app_theme_mode_changed} const AppThemeModeChanged(this.themeMode); - final ThemeMode themeMode; - @override - List get props => [themeMode]; + List get props => [themeMode]; } -/// {@template app_flex_scheme_changed} -/// Event to change the application's FlexColorScheme. -/// {@endtemplate} +/// Dispatched when the accent color theme changes. class AppFlexSchemeChanged extends AppEvent { - /// {@macro app_flex_scheme_changed} const AppFlexSchemeChanged(this.flexScheme); - final FlexScheme flexScheme; - @override - List get props => [flexScheme]; + List get props => [flexScheme]; } -/// {@template app_font_family_changed} -/// Event to change the application's font family. -/// {@endtemplate} +/// Dispatched when the font family changes. class AppFontFamilyChanged extends AppEvent { - /// {@macro app_font_family_changed} const AppFontFamilyChanged(this.fontFamily); - final String? fontFamily; - @override List get props => [fontFamily]; } -/// {@template app_text_scale_factor_changed} -/// Event to change the application's text scale factor. -/// {@endtemplate} +/// Dispatched when the text scale factor changes. class AppTextScaleFactorChanged extends AppEvent { - /// {@macro app_text_scale_factor_changed} const AppTextScaleFactorChanged(this.appTextScaleFactor); - final AppTextScaleFactor appTextScaleFactor; - @override - List get props => [appTextScaleFactor]; + List get props => [appTextScaleFactor]; } -/// {@template app_font_weight_changed} -/// Event to change the application's font weight. -/// {@endtemplate} +/// Dispatched when the font weight changes. class AppFontWeightChanged extends AppEvent { - /// {@macro app_font_weight_changed} const AppFontWeightChanged(this.fontWeight); - - /// The new font weight to apply. final AppFontWeight fontWeight; - @override List get props => [fontWeight]; } -/// {@template app_config_fetch_requested} -/// Event to trigger fetching of the global AppConfig. -/// {@endtemplate} -class AppConfigFetchRequested extends AppEvent { - /// {@macro app_config_fetch_requested} - const AppConfigFetchRequested(); -} - -/// {@template app_opened} -/// Event triggered when the application is opened. -/// Used to check for required updates or maintenance mode. -/// {@endtemplate} -class AppOpened extends AppEvent { - /// {@macro app_opened} - const AppOpened(); -} - -/// {@template app_user_account_action_shown} -/// Event triggered when an AccountAction has been shown to the user, -/// prompting an update to their `lastAccountActionShownAt` timestamp. -/// {@endtemplate} +/// Dispatched when a one-time user account action has been shown. class AppUserAccountActionShown extends AppEvent { - /// {@macro app_user_account_action_shown} const AppUserAccountActionShown({ required this.userId, required this.feedActionType, required this.isCompleted, }); - final String userId; final FeedActionType feedActionType; final bool isCompleted; - @override List get props => [userId, feedActionType, isCompleted]; } diff --git a/lib/app/services/app_status_service.dart b/lib/app/services/app_status_service.dart index a27f4cc..2a2bb7a 100644 --- a/lib/app/services/app_status_service.dart +++ b/lib/app/services/app_status_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/config/config.dart'; /// {@template app_status_service} /// A service dedicated to monitoring the application's lifecycle and @@ -28,8 +29,10 @@ class AppStatusService with WidgetsBindingObserver { AppStatusService({ required BuildContext context, required Duration checkInterval, + required AppEnvironment environment, }) : _context = context, - _checkInterval = checkInterval { + _checkInterval = checkInterval, + _environment = environment { // Immediately register this service as a lifecycle observer. WidgetsBinding.instance.addObserver(this); // Start the periodic checks. @@ -42,6 +45,9 @@ class AppStatusService with WidgetsBindingObserver { /// The interval at which to perform periodic status checks. final Duration _checkInterval; + /// The current application environment. + final AppEnvironment _environment; + /// The timer responsible for periodic checks. Timer? _timer; @@ -54,11 +60,20 @@ class AppStatusService with WidgetsBindingObserver { _timer?.cancel(); // Create a new periodic timer. _timer = Timer.periodic(_checkInterval, (_) { + // In demo mode, periodic checks are not needed as there's no backend. + if (_environment == AppEnvironment.demo) { + print( + '[AppStatusService] Demo mode: Skipping periodic check.', + ); + return; + } print( '[AppStatusService] Periodic check triggered. Requesting AppConfig fetch.', ); // Add the event to the AppBloc to fetch the latest config. - _context.read().add(const AppConfigFetchRequested()); + _context + .read() + .add(const AppConfigFetchRequested(isBackgroundCheck: true)); }); } @@ -67,6 +82,14 @@ class AppStatusService with WidgetsBindingObserver { /// This method is called whenever the application's lifecycle state changes. @override void didChangeAppLifecycleState(AppLifecycleState state) { + // In demo mode, we disable the app resume check. This is especially + // useful on web, where switching browser tabs would otherwise trigger + // a reload, which is unnecessary and can be distracting for demos. + if (_environment == AppEnvironment.demo) { + print('[AppStatusService] Demo mode: Skipping app lifecycle check.'); + return; + } + // We are only interested in the 'resumed' state. if (state == AppLifecycleState.resumed) { print( @@ -75,7 +98,9 @@ class AppStatusService with WidgetsBindingObserver { // When the app comes to the foreground, immediately trigger a check. // This is crucial for catching maintenance mode that was enabled // while the app was in the background. - _context.read().add(const AppConfigFetchRequested()); + _context + .read() + .add(const AppConfigFetchRequested(isBackgroundCheck: true)); } } diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 3f7c98e..029c37a 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -151,6 +151,7 @@ class _AppViewState extends State<_AppView> { _appStatusService = AppStatusService( context: context, checkInterval: const Duration(minutes: 15), + environment: widget.environment, ); _router = createRouter( @@ -183,40 +184,69 @@ class _AppViewState extends State<_AppView> { @override Widget build(BuildContext context) { // Wrap the part of the tree that needs to react to AppBloc state changes - // (specifically for updating the ValueNotifier) with a BlocListener. - // The BlocBuilder remains for theme changes. + // with a BlocListener and a BlocBuilder. return BlocListener( - // Listen for status changes to update the GoRouter's ValueNotifier + // The BlocListener's primary role here is to keep GoRouter's refresh + // mechanism informed about authentication status changes. + // GoRouter's `redirect` logic depends on this notifier to re-evaluate + // routes when the user logs in or out *while the app is running*. listenWhen: (previous, current) => previous.status != current.status, listener: (context, state) { _statusNotifier.value = state.status; }, + // The BlocBuilder is the core of the new stable startup architecture. + // It functions as a "master switch" for the entire application's UI. + // Based on the AppStatus, it decides whether to show a full-screen + // status page (like Maintenance or Loading) or to build the main + // application UI with its nested router. This approach is fundamental + // to fixing the original race conditions and BuildContext instability. child: BlocBuilder( - // Rebuild the UI based on AppBloc's state (theme, locale, and critical app statuses) builder: (context, state) { - // Defer l10n access until inside a MaterialApp context + // --- Full-Screen Status Pages --- + // The following states represent critical, app-wide conditions that + // must be handled before the main router and UI are displayed. + // By returning a dedicated widget here, we ensure these pages are + // full-screen and exist outside the main app's navigation shell. - // Handle critical RemoteConfig loading states globally - // These checks have the highest priority and will lock the entire UI. - // - // Check for Maintenance Mode. if (state.status == AppStatus.underMaintenance) { - return const MaterialApp( + // The app is in maintenance mode. Show the MaintenancePage. + // + // WHY A SEPARATE MATERIALAPP? + // Each status page is wrapped in its own simple MaterialApp to create + // a self-contained environment. This provides the necessary + // Directionality, theme, and localization context for the page + // to render correctly, without needing the main app's router. + // + // WHY A DEFAULT THEME? + // The theme uses hardcoded, sensible defaults (like FlexScheme.material) + // because at this early stage, we only need a basic visual structure. + // However, we critically use `state.themeMode` and `state.locale`, + // which are loaded from user settings *before* the maintenance check, + // ensuring the page respects the user's chosen light/dark mode and language. + return MaterialApp( debugShowCheckedModeBanner: false, - home: MaintenancePage(), + theme: lightTheme( + scheme: FlexScheme.material, + appTextScaleFactor: AppTextScaleFactor.medium, + appFontWeight: AppFontWeight.regular, + fontFamily: null, + ), + darkTheme: darkTheme( + scheme: FlexScheme.material, + appTextScaleFactor: AppTextScaleFactor.medium, + appFontWeight: AppFontWeight.regular, + fontFamily: null, + ), + themeMode: state.themeMode, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: state.locale, + home: const MaintenancePage(), ); } - // Check for a Required Update. if (state.status == AppStatus.updateRequired) { - return const MaterialApp( - debugShowCheckedModeBanner: false, - home: UpdateRequiredPage(), - ); - } - - // Check for Config Fetching state. - if (state.status == AppStatus.configFetching) { + // A mandatory update is required. Show the UpdateRequiredPage. return MaterialApp( debugShowCheckedModeBanner: false, theme: lightTheme( @@ -229,36 +259,21 @@ class _AppViewState extends State<_AppView> { scheme: FlexScheme.material, appTextScaleFactor: AppTextScaleFactor.medium, appFontWeight: AppFontWeight.regular, - fontFamily: null, // System default font - ), - themeMode: state - .themeMode, // Still respect light/dark if available from system - localizationsDelegates: const [ - ...AppLocalizations.localizationsDelegates, - ...UiKitLocalizations.localizationsDelegates, - ], - supportedLocales: const [ - ...AppLocalizations.supportedLocales, - ...UiKitLocalizations.supportedLocales, - ], - home: Scaffold( - body: Builder( - // Use Builder to get context under MaterialApp - builder: (innerContext) { - return LoadingStateWidget( - icon: Icons.settings_applications_outlined, - headline: AppLocalizations.of( - innerContext, - ).headlinesFeedLoadingHeadline, - subheadline: AppLocalizations.of(innerContext).pleaseWait, - ); - }, - ), + fontFamily: null, ), + themeMode: state.themeMode, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: state.locale, + home: const UpdateRequiredPage(), ); } - if (state.status == AppStatus.configFetchFailed) { + if (state.status == AppStatus.configFetching || + state.status == AppStatus.configFetchFailed) { + // The app is in the process of fetching its initial remote + // configuration or has failed to do so. The StatusPage handles + // both the loading indicator and the retry mechanism. return MaterialApp( debugShowCheckedModeBanner: false, theme: lightTheme( @@ -274,49 +289,30 @@ class _AppViewState extends State<_AppView> { fontFamily: null, ), themeMode: state.themeMode, - localizationsDelegates: const [ - ...AppLocalizations.localizationsDelegates, - ...UiKitLocalizations.localizationsDelegates, - ], - supportedLocales: const [ - ...AppLocalizations.supportedLocales, - ...UiKitLocalizations.supportedLocales, - ], - home: Scaffold( - body: Builder( - // Use Builder to get context under MaterialApp - builder: (innerContext) { - return FailureStateWidget( - exception: const NetworkException(), - retryButtonText: UiKitLocalizations.of( - innerContext, - )!.retryButtonText, - onRetry: () { - // Use outer context for BLoC access - context.read().add( - const AppConfigFetchRequested(), - ); - }, - ); - }, - ), - ), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: state.locale, + home: const StatusPage(), ); } - // If config is loaded (or not in a failed/fetching state for config), proceed with main app UI - // It's safe to access l10n here if needed for print statements, - // as this path implies we are about to build the main MaterialApp.router - // which provides localizations. - // final l10n = context.l10n; - print('[_AppViewState] Building MaterialApp.router'); - print('[_AppViewState] state.fontFamily: ${state.fontFamily}'); - print( - '[_AppViewState] state.settings.displaySettings.fontFamily: ${state.settings.displaySettings.fontFamily}', - ); - print( - '[_AppViewState] state.settings.displaySettings.fontWeight: ${state.settings.displaySettings.fontWeight}', - ); + // --- Main Application UI --- + // If none of the critical states above are met, the app is ready + // to display its main UI. We build the MaterialApp.router here. + // This is the single, STABLE root widget for the entire main app. + // + // WHY IS THIS SO IMPORTANT? + // Because this widget is now built conditionally inside a single + // BlocBuilder, it is created only ONCE when the app enters a + // "running" state (e.g., authenticated, anonymous). It is no longer + // destroyed and rebuilt during startup, which was the root cause of + // the `BuildContext` instability and the `l10n` crashes. + // + // THEME CONFIGURATION: + // Unlike the status pages, this MaterialApp is themed using the full, + // detailed settings loaded into the AppState (e.g., `state.flexScheme`, + // `state.settings.displaySettings...`), providing the complete, + // personalized user experience. return MaterialApp.router( debugShowCheckedModeBanner: false, themeMode: state.themeMode, diff --git a/lib/router/router.dart b/lib/router/router.dart index bcbd0bc..9106ca6 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -73,134 +73,94 @@ GoRouter createRouter({ return GoRouter( refreshListenable: authStatusNotifier, - initialLocation: Routes.feed, + // Start at a neutral root path. The redirect logic will immediately + // determine the correct path (/feed or /authentication), preventing + // an attempt to build a complex page before the app state is ready. + initialLocation: '/', debugLogDiagnostics: true, // --- Redirect Logic --- redirect: (BuildContext context, GoRouterState state) { final appStatus = context.read().state.status; - final appConfig = context.read().state.remoteConfig; final currentLocation = state.matchedLocation; - final currentUri = state.uri; print( 'GoRouter Redirect Check:\n' ' Current Location (Matched): $currentLocation\n' - ' Current URI (Full): $currentUri\n' - ' AppStatus: $appStatus\n' - ' AppConfig isNull: ${appConfig == null}', + ' AppStatus: $appStatus', ); // --- Define Key Paths --- + const rootPath = '/'; const authenticationPath = Routes.authentication; const feedPath = Routes.feed; final isGoingToAuth = currentLocation.startsWith(authenticationPath); - // --- Case 0: App is Initializing or Config is being fetched/failed --- - if (appStatus == AppStatus.initial || - appStatus == AppStatus.configFetching || - appStatus == AppStatus.configFetchFailed) { - // If AppStatus is initial and trying to go to a non-auth page (e.g. initial /feed) - // redirect to auth immediately to settle auth status first. - if (appStatus == AppStatus.initial && !isGoingToAuth) { - print( - ' Redirect Decision: AppStatus is INITIAL and not going to auth. Redirecting to $authenticationPath to settle auth first.', - ); - return authenticationPath; - } - // For configFetching or configFetchFailed, or initial going to auth, - // let the App widget's builder handle the UI (loading/error screen). - print( - ' Redirect Decision: AppStatus is $appStatus. Allowing App widget to handle display or navigation to auth.', - ); - return null; - } + // With the new App startup architecture, the router is only active when + // the app is in a stable, running state. The `redirect` function's + // only responsibility is to handle auth-based route protection. + // States like `configFetching`, `underMaintenance`, etc., are now + // handled by the root App widget *before* this router is ever built. - // --- Case 1: Unauthenticated User (after initial phase, config not relevant yet for this decision) --- + // --- Case 1: Unauthenticated User --- + // If the user is unauthenticated, they should be on an auth path. + // If they are trying to access any other part of the app, redirect them. if (appStatus == AppStatus.unauthenticated) { - print(' Redirect Decision: User is UNauthenticated.'); - if (!isGoingToAuth) { - print( - ' Action: Not going to auth. Redirecting to $authenticationPath', - ); - return authenticationPath; - } - print(' Action: Already going to auth. Allowing navigation.'); - return null; + print(' Redirect: User is unauthenticated.'); + // If they are already on an auth path, allow it. Otherwise, redirect. + return isGoingToAuth ? null : authenticationPath; } // --- Case 2: Anonymous or Authenticated User --- - // (Covers AppStatus.anonymous and AppStatus.authenticated) - // At this point, AppConfig should be loaded or its loading/error state is handled by App widget. - // The main concern here is preventing authenticated users from re-entering basic auth flows. + // If a user is anonymous or authenticated, they should not be able to + // access the main authentication flows, with an exception for account + // linking for anonymous users. if (appStatus == AppStatus.anonymous || appStatus == AppStatus.authenticated) { - print(' Redirect Decision: User is $appStatus.'); - - final isLinkingContextQueryPresent = - state.uri.queryParameters['context'] == 'linking'; - final isLinkingPathSegmentPresent = currentLocation.contains( - '/linking/', - ); + print(' Redirect: User is $appStatus.'); - // Determine if the current location is part of any linking flow (either via query or path segment) - final isAnyLinkingContext = - isLinkingContextQueryPresent || isLinkingPathSegmentPresent; - - // If an authenticated/anonymous user is on any authentication-related path: - if (currentLocation.startsWith(authenticationPath)) { - print( - ' Debug: Auth path detected. Current Location: $currentLocation', - ); - print( - ' Debug: URI Query Parameters: ${state.uri.queryParameters}', - ); - print( - ' Debug: isLinkingContextQueryPresent: $isLinkingContextQueryPresent', - ); - print( - ' Debug: isLinkingPathSegmentPresent: $isLinkingPathSegmentPresent', - ); - print( - ' Debug: isAnyLinkingContext evaluated to: $isAnyLinkingContext', - ); - - // If the user is authenticated, always redirect away from auth paths. + // If the user is trying to access an authentication path: + if (isGoingToAuth) { + // A fully authenticated user should never see auth pages. if (appStatus == AppStatus.authenticated) { - print( - ' Action: Authenticated user on auth path ($currentLocation). Redirecting to $feedPath', - ); + print(' Action: Authenticated user on auth path. Redirecting to feed.'); return feedPath; } - // If the user is anonymous, allow navigation within auth paths if in a linking context. - // Otherwise, redirect anonymous users trying to access non-linking auth paths to feed. - if (isAnyLinkingContext) { - print( - ' Action: Anonymous user on auth linking path ($currentLocation). Allowing navigation.', - ); + // An anonymous user is only allowed on auth paths for account linking. + final isLinking = + state.uri.queryParameters['context'] == 'linking' || + currentLocation.contains('/linking/'); + + if (isLinking) { + print(' Action: Anonymous user on linking path. Allowing.'); return null; } else { - print( - ' Action: Anonymous user trying to access non-linking auth path ($currentLocation). Redirecting to $feedPath', - ); + print(' Action: Anonymous user on non-linking auth path. Redirecting to feed.'); return feedPath; } } - // Allow access to other routes (non-auth paths) - print( - ' Action: Allowing navigation to $currentLocation for $appStatus user (non-auth path).', - ); - return null; + + // If the user is at the root path, they should be sent to the feed. + if (currentLocation == rootPath) { + print(' Action: User at root. Redirecting to feed.'); + return feedPath; + } } - // Fallback (should ideally not be reached if all statuses are handled) - print( - ' Redirect Decision: Fallback, no specific condition met for $appStatus. Allowing navigation.', - ); + // --- Fallback --- + // For any other case, allow navigation. + print(' Redirect: No condition met. Allowing navigation.'); return null; }, // --- Authentication Routes --- routes: [ + // A neutral root route that the app starts on. The redirect logic will + // immediately move the user to the correct location. This route's + // builder will never be called in practice. + GoRoute( + path: '/', + builder: (context, state) => const SizedBox.shrink(), + ), GoRoute( path: Routes.authentication, name: Routes.authenticationName, diff --git a/lib/status/view/status_page.dart b/lib/status/view/status_page.dart new file mode 100644 index 0000000..e477245 --- /dev/null +++ b/lib/status/view/status_page.dart @@ -0,0 +1,53 @@ +import 'package:core/core.dart' hide AppStatus; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// A page that serves as the root UI during the critical startup sequence. +/// +/// This widget is displayed *before* the main application's router and UI +/// shell are built. It is responsible for showing the user a clear status +/// while the remote configuration is being fetched, and it provides a way +/// for the user to retry if the fetch operation fails. +class StatusPage extends StatelessWidget { + /// {@macro status_page} + const StatusPage({super.key}); + + @override + Widget build(BuildContext context) { + // This page is a temporary root widget shown during the critical + // startup phase before the main app UI (and GoRouter) is built. + // It handles two key states: fetching the remote configuration and + // recovering from a failed fetch. + return Scaffold( + body: BlocBuilder( + builder: (context, state) { + final l10n = AppLocalizationsX(context).l10n; + + if (state.status == AppStatus.configFetching) { + // While fetching configuration, display a clear loading indicator. + // This uses a shared widget from the UI kit for consistency. + return LoadingStateWidget( + icon: Icons.settings_applications_outlined, + headline: l10n.headlinesFeedLoadingHeadline, + subheadline: l10n.pleaseWait, + ); + } + + // If fetching fails, show an error message with a retry option. + // This allows the user to recover from transient network issues. + return FailureStateWidget( + exception: const NetworkException(), // A generic network error + retryButtonText: 'l10n.retryButtonText', //TODO(fulleni): localize me. + onRetry: () { + // Dispatch the event to AppBloc to re-trigger the fetch. + context.read().add(const AppConfigFetchRequested()); + }, + ); + }, + ), + ); + } +} diff --git a/lib/status/view/view.dart b/lib/status/view/view.dart index 7c24eda..9ef4196 100644 --- a/lib/status/view/view.dart +++ b/lib/status/view/view.dart @@ -1,2 +1,3 @@ export 'maintenance_page.dart'; +export 'status_page.dart'; export 'update_required_page.dart';