diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index a6241331..5f69c91b 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/authentication/b import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; // Import for Logger import 'package:ui_kit/ui_kit.dart'; /// {@template account_view} @@ -16,6 +17,9 @@ class AccountPage extends StatelessWidget { /// {@macro account_view} const AccountPage({super.key}); + // Logger instance for AccountPage + static final _logger = Logger('AccountPage'); + @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -27,6 +31,7 @@ class AccountPage extends StatelessWidget { final theme = Theme.of(context); final textTheme = theme.textTheme; + // Removed BlocListener as per user instruction. return Scaffold( appBar: AppBar( title: Text(l10n.accountPageTitle, style: textTheme.titleLarge), @@ -102,7 +107,6 @@ class AccountPage extends StatelessWidget { statusWidget = Padding( padding: const EdgeInsets.only(top: AppSpacing.md), child: ElevatedButton.icon( - // Changed to ElevatedButton icon: const Icon(Icons.link_outlined), label: Text(l10n.accountSignInPromptButton), style: ElevatedButton.styleFrom( @@ -113,10 +117,16 @@ class AccountPage extends StatelessWidget { textStyle: textTheme.labelLarge, ), onPressed: () { - context.goNamed( - Routes.authenticationName, - queryParameters: {'context': 'linking'}, + _logger.info( + 'AccountPage: "Link Account" button pressed. ' + 'Dispatching AuthenticationLinkingInitiated event and navigating to authentication page with AuthFlow.linkAccount extra.', + ); + // Dispatch the event to set the AuthFlow in AuthenticationBloc + context.read().add( + const AuthenticationLinkingInitiated(), ); + // Navigate to the authentication page + context.pushNamed(Routes.authenticationName); }, ), ); @@ -127,7 +137,6 @@ class AccountPage extends StatelessWidget { children: [ const SizedBox(height: AppSpacing.md), OutlinedButton.icon( - // Changed to OutlinedButton.icon icon: Icon(Icons.logout, color: colorScheme.error), label: Text(l10n.accountSignOutTile), style: OutlinedButton.styleFrom( @@ -140,6 +149,7 @@ class AccountPage extends StatelessWidget { textStyle: textTheme.labelLarge, ), onPressed: () { + _logger.info('AccountPage: "Sign Out" button pressed.'); context.read().add( const AuthenticationSignOutRequested(), ); diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index f0972f6b..c1cd913f 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -12,6 +12,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/config/confi import 'package:flutter_news_app_mobile_client_full_source_code/app/services/demo_data_initializer_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/services/demo_data_migration_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/services/package_info_service.dart'; +import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:pub_semver/pub_semver.dart'; @@ -79,6 +80,7 @@ class AppBloc extends Bloc { on(_onAppUserFeedDecoratorShown); on(_onAppUserContentPreferencesChanged); on(_onLogoutRequested); + on(_onPostAuthRedirectIntentCaptured); // Subscribe to the authentication repository's authStateChanges stream. // This stream is the single source of truth for the user's auth state @@ -408,6 +410,19 @@ class AppBloc extends Bloc { // After potential initialization and migration, // ensure user-specific data (settings and preferences) are loaded. await _fetchAndSetUserData(newUser, emit); + + // After user data is loaded, check for a pending redirect intent. + final redirectIntent = state.postAuthRedirectIntent; + if (redirectIntent != null) { + _logger.info( + '[AppBloc] Post-authentication redirect intent found: ' + '${redirectIntent.matchedLocation}. Navigating...', + ); + // Use the navigatorKey's context to navigate to the intended route. + _navigatorKey.currentState?.context.go(redirectIntent.matchedLocation); + // Clear the intent after navigation to prevent re-triggering. + emit(state.copyWith(clearPostAuthRedirectIntent: true)); + } } else { // If user logs out, clear user-specific data from state. emit(state.copyWith(settings: null, userContentPreferences: null)); @@ -802,4 +817,14 @@ class AppBloc extends Bloc { ); } } + + /// Handles [PostAuthRedirectIntentCaptured] events. + /// + /// Stores the intended navigation path in the state. + void _onPostAuthRedirectIntentCaptured( + PostAuthRedirectIntentCaptured event, + Emitter emit, + ) { + emit(state.copyWith(postAuthRedirectIntent: event.intent)); + } } diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 682e8404..5c215783 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -156,3 +156,18 @@ class AppUserFeedDecoratorShown extends AppEvent { @override List get props => [userId, feedDecoratorType, isCompleted]; } + +/// {@template post_auth_redirect_intent_captured} +/// Event triggered when a navigation intent is captured before an authentication +/// flow, indicating where the user should be redirected after successful auth. +/// {@endtemplate} +final class PostAuthRedirectIntentCaptured extends AppEvent { + /// {@macro post_auth_redirect_intent_captured} + const PostAuthRedirectIntentCaptured({required this.intent}); + + /// The [GoRouterState] representing the intended destination. + final GoRouterState intent; + + @override + List get props => [intent]; +} diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 50e68b78..5b421457 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -9,13 +9,18 @@ enum AppLifeCycleStatus { /// The application is currently loading user-specific data (settings, preferences). loadingUserData, - /// The user is not authenticated. + /// The user is not authenticated. This state indicates that there is no + /// active user session at all, and the user needs to either sign in or + /// sign up. unauthenticated, - /// The user is authenticated (e.g., standard user). + /// The user is authenticated (e.g., standard user). This state indicates + /// a full, permanent user session is active. authenticated, - /// The user is anonymous (e.g., guest user). + /// The user is anonymous (e.g., guest user). This state indicates a temporary + /// user session is active, allowing limited functionality before a full + /// account is created or linked. anonymous, /// A critical error occurred during application startup, @@ -49,6 +54,8 @@ class AppState extends Equatable { this.settings, this.selectedBottomNavigationIndex = 0, this.currentAppVersion, + // New property to store the intended navigation path after authentication. + this.postAuthRedirectIntent, }); /// The current status of the application, indicating its lifecycle stage. @@ -89,6 +96,12 @@ class AppState extends Equatable { /// This is used for version enforcement. final String? currentAppVersion; + /// Stores the intended navigation path (GoRouterState) that the user was + /// trying to access before being redirected for authentication. + /// This is used to redirect the user back to their original destination + /// after successful login or account linking. + final GoRouterState? postAuthRedirectIntent; + /// The latest required app version from the remote configuration. /// Returns `null` if remote config is not available. String? get latestAppVersion => remoteConfig?.appStatus.latestAppVersion; @@ -162,6 +175,7 @@ class AppState extends Equatable { selectedBottomNavigationIndex, environment, currentAppVersion, + postAuthRedirectIntent, ]; /// Creates a copy of this [AppState] with the given fields replaced with @@ -178,6 +192,8 @@ class AppState extends Equatable { int? selectedBottomNavigationIndex, local_config.AppEnvironment? environment, String? currentAppVersion, + GoRouterState? postAuthRedirectIntent, + bool clearPostAuthRedirectIntent = false, }) { return AppState( status: status ?? this.status, @@ -194,6 +210,9 @@ class AppState extends Equatable { selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex, environment: environment ?? this.environment, currentAppVersion: currentAppVersion ?? this.currentAppVersion, + postAuthRedirectIntent: clearPostAuthRedirectIntent + ? null + : postAuthRedirectIntent ?? this.postAuthRedirectIntent, ); } } diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index d307ca84..48787e94 100644 --- a/lib/authentication/bloc/authentication_bloc.dart +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -4,6 +4,7 @@ import 'package:auth_repository/auth_repository.dart'; import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; +import 'package:logging/logging.dart'; // Import for Logger part 'authentication_event.dart'; part 'authentication_state.dart'; @@ -18,6 +19,7 @@ class AuthenticationBloc /// {@macro authentication_bloc} AuthenticationBloc({required AuthRepository authenticationRepository}) : _authenticationRepository = authenticationRepository, + _logger = Logger('AuthenticationBloc'), // Initialize logger super(const AuthenticationState()) { // Listen to authentication state changes from the repository _userAuthSubscription = _authenticationRepository.authStateChanges.listen( @@ -34,31 +36,58 @@ class AuthenticationBloc ); on(_onAuthenticationSignOutRequested); on(_onAuthenticationCooldownCompleted); + on(_onAuthenticationLinkingInitiated); + on(_onAuthenticationFlowReset); } final AuthRepository _authenticationRepository; + final Logger _logger; // Declare logger late final StreamSubscription _userAuthSubscription; Timer? _cooldownTimer; /// Handles [_AuthenticationUserChanged] events. + /// + /// Updates the authentication status and user, and resets the authentication + /// flow to `signIn` if the user becomes unauthenticated. Future _onAuthenticationUserChanged( _AuthenticationUserChanged event, Emitter emit, ) async { + _logger.info( + '[_onAuthenticationUserChanged] Event received. ' + 'Old User ID: ${state.user?.id}, New User ID: ${event.user?.id}. ' + 'Current AuthFlow: ${state.flow}.', + ); + if (event.user != null) { emit( state.copyWith( status: AuthenticationStatus.authenticated, user: event.user, + // When a user is authenticated, ensure the flow is reset to signIn + // unless it's explicitly a linking flow that just completed. + // For now, we reset to signIn as the linking context is handled + // by the router redirect. + flow: AuthFlow.signIn, ), ); + _logger.info( + '[_onAuthenticationUserChanged] User authenticated. ' + 'New state status: ${state.status}, New AuthFlow: ${state.flow}.', + ); } else { emit( state.copyWith( status: AuthenticationStatus.unauthenticated, user: null, + // When a user logs out, reset the flow to standard sign-in. + flow: AuthFlow.signIn, ), ); + _logger.info( + '[_onAuthenticationUserChanged] User unauthenticated. ' + 'New state status: ${state.status}, New AuthFlow: ${state.flow}.', + ); } } @@ -67,12 +96,26 @@ class AuthenticationBloc AuthenticationRequestSignInCodeRequested event, Emitter emit, ) async { + _logger.info( + '[_onAuthenticationRequestSignInCodeRequested] Event received. ' + 'Requesting sign-in code for email: ${event.email}. ' + 'Current AuthFlow: ${state.flow}.', + ); if (state.cooldownEndTime != null && state.cooldownEndTime!.isAfter(DateTime.now())) { + _logger.warning( + '[_onAuthenticationRequestSignInCodeRequested] Cooldown active. ' + 'Skipping request for email: ${event.email}. ' + 'Cooldown ends at: ${state.cooldownEndTime}.', + ); return; } emit(state.copyWith(status: AuthenticationStatus.requestCodeInProgress)); + _logger.info( + '[_onAuthenticationRequestSignInCodeRequested] Status set to requestCodeInProgress. ' + 'Current AuthFlow: ${state.flow}.', + ); try { await _authenticationRepository.requestSignInCode(event.email); final cooldownEndTime = DateTime.now().add(_requestCodeCooldownDuration); @@ -83,6 +126,11 @@ class AuthenticationBloc cooldownEndTime: cooldownEndTime, ), ); + _logger.info( + '[_onAuthenticationRequestSignInCodeRequested] Sign-in code requested successfully for email: ${event.email}. ' + 'Status set to requestCodeSuccess. Cooldown ends at: $cooldownEndTime. ' + 'Current AuthFlow: ${state.flow}.', + ); _cooldownTimer?.cancel(); _cooldownTimer = Timer( @@ -90,8 +138,18 @@ class AuthenticationBloc () => add(const AuthenticationCooldownCompleted()), ); } on HttpException catch (e) { + _logger.severe( + '[_onAuthenticationRequestSignInCodeRequested] Failed to request sign-in code for email: ${event.email}. ' + 'Exception: ${e.runtimeType} - ${e.message}. ' + 'Current AuthFlow: ${state.flow}.', + ); emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { + _logger.severe( + '[_onAuthenticationRequestSignInCodeRequested] Unexpected error requesting sign-in code for email: ${event.email}. ' + 'Error: $e. ' + 'Current AuthFlow: ${state.flow}.', + ); emit( state.copyWith( status: AuthenticationStatus.failure, @@ -106,14 +164,39 @@ class AuthenticationBloc AuthenticationVerifyCodeRequested event, Emitter emit, ) async { + _logger.info( + '[_onAuthenticationVerifyCodeRequested] Event received. ' + 'Verifying code for email: ${event.email}. ' + 'Current AuthFlow: ${state.flow}.', + ); emit(state.copyWith(status: AuthenticationStatus.loading)); + _logger.info( + '[_onAuthenticationVerifyCodeRequested] Status set to loading. ' + 'Current AuthFlow: ${state.flow}.', + ); try { await _authenticationRepository.verifySignInCode(event.email, event.code); // On success, the _AuthenticationUserChanged listener will handle // emitting the authenticated state. + // Also, explicitly reset the flow to signIn after successful verification. + emit(state.copyWith(flow: AuthFlow.signIn)); + _logger.info( + '[_onAuthenticationVerifyCodeRequested] Code verified successfully for email: ${event.email}. ' + 'AuthFlow reset to: ${state.flow}.', + ); } on HttpException catch (e) { + _logger.severe( + '[_onAuthenticationVerifyCodeRequested] Failed to verify code for email: ${event.email}. ' + 'Exception: ${e.runtimeType} - ${e.message}. ' + 'Current AuthFlow: ${state.flow}.', + ); emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { + _logger.severe( + '[_onAuthenticationVerifyCodeRequested] Unexpected error verifying code for email: ${event.email}. ' + 'Error: $e. ' + 'Current AuthFlow: ${state.flow}.', + ); emit( state.copyWith( status: AuthenticationStatus.failure, @@ -128,14 +211,38 @@ class AuthenticationBloc AuthenticationAnonymousSignInRequested event, Emitter emit, ) async { + _logger.info( + '[_onAuthenticationAnonymousSignInRequested] Event received. ' + 'Anonymous sign-in requested. Current AuthFlow: ${state.flow}.', + ); emit(state.copyWith(status: AuthenticationStatus.loading)); + _logger.info( + '[_onAuthenticationAnonymousSignInRequested] Status set to loading. ' + 'Current AuthFlow: ${state.flow}.', + ); try { await _authenticationRepository.signInAnonymously(); // On success, the _AuthenticationUserChanged listener will handle // emitting the authenticated state. + // Also, explicitly reset the flow to signIn after successful anonymous sign-in. + emit(state.copyWith(flow: AuthFlow.signIn)); + _logger.info( + '[_onAuthenticationAnonymousSignInRequested] Anonymous sign-in successful. ' + 'AuthFlow reset to: ${state.flow}.', + ); } on HttpException catch (e) { + _logger.severe( + '[_onAuthenticationAnonymousSignInRequested] Failed anonymous sign-in. ' + 'Exception: ${e.runtimeType} - ${e.message}. ' + 'Current AuthFlow: ${state.flow}.', + ); emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { + _logger.severe( + '[_onAuthenticationAnonymousSignInRequested] Unexpected error during anonymous sign-in. ' + 'Error: $e. ' + 'Current AuthFlow: ${state.flow}.', + ); emit( state.copyWith( status: AuthenticationStatus.failure, @@ -146,18 +253,44 @@ class AuthenticationBloc } /// Handles [AuthenticationSignOutRequested] events. + /// + /// Resets the authentication flow to `signIn` upon sign-out. Future _onAuthenticationSignOutRequested( AuthenticationSignOutRequested event, Emitter emit, ) async { + _logger.info( + '[_onAuthenticationSignOutRequested] Event received. ' + 'Sign-out requested. Current AuthFlow: ${state.flow}.', + ); emit(state.copyWith(status: AuthenticationStatus.loading)); + _logger.info( + '[_onAuthenticationSignOutRequested] Status set to loading. ' + 'Current AuthFlow: ${state.flow}.', + ); try { await _authenticationRepository.signOut(); // On success, the _AuthenticationUserChanged listener will handle // emitting the unauthenticated state. + // Also, explicitly reset the flow to signIn. + emit(state.copyWith(flow: AuthFlow.signIn)); + _logger.info( + '[_onAuthenticationSignOutRequested] Sign-out successful. ' + 'AuthFlow reset to: ${state.flow}.', + ); } on HttpException catch (e) { + _logger.severe( + '[_onAuthenticationSignOutRequested] Failed to sign out. ' + 'Exception: ${e.runtimeType} - ${e.message}. ' + 'Current AuthFlow: ${state.flow}.', + ); emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { + _logger.severe( + '[_onAuthenticationSignOutRequested] Unexpected error during sign-out. ' + 'Error: $e. ' + 'Current AuthFlow: ${state.flow}.', + ); emit( state.copyWith( status: AuthenticationStatus.failure, @@ -178,11 +311,59 @@ class AuthenticationBloc AuthenticationCooldownCompleted event, Emitter emit, ) { + _logger.info( + '[_onAuthenticationCooldownCompleted] Event received. ' + 'Cooldown completed. Current AuthFlow: ${state.flow}.', + ); emit( state.copyWith( status: AuthenticationStatus.initial, clearCooldownEndTime: true, ), ); + _logger.info( + '[_onAuthenticationCooldownCompleted] Status set to initial, cooldown cleared. ' + 'Current AuthFlow: ${state.flow}.', + ); + } + + /// Handles [AuthenticationLinkingInitiated] events. + /// + /// Sets the authentication flow to `linkAccount`. This is dispatched by the + /// UI (e.g., `AccountPage`) when an anonymous user explicitly chooses to + /// link their account, signaling the `AuthenticationBloc` to prepare for + /// the account linking process. + void _onAuthenticationLinkingInitiated( + AuthenticationLinkingInitiated event, + Emitter emit, + ) { + _logger.info( + '[_onAuthenticationLinkingInitiated] Event received. ' + 'Account linking initiated. Setting flow to AuthFlow.linkAccount. ' + 'Previous AuthFlow: ${state.flow}.', + ); + emit(state.copyWith(flow: AuthFlow.linkAccount)); + _logger.info( + '[_onAuthenticationLinkingInitiated] AuthFlow updated to: ${state.flow}.', + ); + } + + /// Handles [AuthenticationFlowReset] events. + /// + /// Resets the authentication flow to `signIn`. This is used to ensure + /// a clean state for the authentication UI when it is dismissed or + /// after a successful authentication flow (e.g., account linking). + void _onAuthenticationFlowReset( + AuthenticationFlowReset event, + Emitter emit, + ) { + _logger.info( + '[_onAuthenticationFlowReset] Event received. ' + 'Resetting authentication flow to signIn. Previous AuthFlow: ${state.flow}.', + ); + emit(state.copyWith(flow: AuthFlow.signIn)); + _logger.info( + '[_onAuthenticationFlowReset] AuthFlow reset to: ${state.flow}.', + ); } } diff --git a/lib/authentication/bloc/authentication_event.dart b/lib/authentication/bloc/authentication_event.dart index a667174c..0b131e5a 100644 --- a/lib/authentication/bloc/authentication_event.dart +++ b/lib/authentication/bloc/authentication_event.dart @@ -84,3 +84,31 @@ final class AuthenticationCooldownCompleted extends AuthenticationEvent { /// {@macro authentication_cooldown_completed} const AuthenticationCooldownCompleted(); } + +/// {@template authentication_linking_initiated} +/// Event triggered when an anonymous user initiates the account linking flow. +/// +/// This event must be dispatched *before* navigating to the authentication +/// route (`Routes.authenticationName`) when an anonymous user intends to +/// link their account to an email. It sets the `AuthFlow` in the +/// `AuthenticationBloc` to `linkAccount`, which is then used by the +/// `GoRouter`'s redirect logic to permit access to the authentication UI +/// in the correct context. +/// {@endtemplate} +final class AuthenticationLinkingInitiated extends AuthenticationEvent { + /// {@macro authentication_linking_initiated} + const AuthenticationLinkingInitiated(); +} + +/// {@template authentication_flow_reset} +/// Event triggered to reset the authentication flow context. +/// +/// This event is dispatched when the authentication UI is dismissed +/// or when an authentication flow (like linking an account) has successfully +/// completed, ensuring that the `AuthFlow` state in the `AuthenticationBloc` +/// reverts to `signIn` for subsequent authentication attempts or a clean state. +/// {@endtemplate} +final class AuthenticationFlowReset extends AuthenticationEvent { + /// {@macro authentication_flow_reset} + const AuthenticationFlowReset(); +} diff --git a/lib/authentication/bloc/authentication_state.dart b/lib/authentication/bloc/authentication_state.dart index 5664a91a..2f0b064e 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -24,6 +24,22 @@ enum AuthenticationStatus { failure, } +/// Defines the different authentication flows the user might be in. +/// +/// This enum is crucial for distinguishing between a standard sign-in/sign-up +/// process and an account linking process for an existing anonymous user. +/// The `GoRouter`'s redirect logic relies on this flow to determine +/// appropriate navigation. +enum AuthFlow { + /// Standard sign-in/sign-up flow, where a user is either creating a new + /// account or logging into an existing one. + signIn, + + /// Account linking flow, specifically for an anonymous user who wishes to + /// associate their current anonymous session with a permanent email account. + linkAccount, +} + /// {@template authentication_state} /// Represents the state of the authentication process. /// @@ -40,6 +56,8 @@ class AuthenticationState extends Equatable { this.email, this.exception, this.cooldownEndTime, + // Initialize the authentication flow to standard sign-in by default. + this.flow = AuthFlow.signIn, }); /// The current status of the authentication process. @@ -57,6 +75,13 @@ class AuthenticationState extends Equatable { /// The time when the cooldown for requesting a new code ends. final DateTime? cooldownEndTime; + /// The current authentication flow (e.g., standard sign-in or account linking). + /// + /// This property is critical for the `GoRouter`'s redirect logic, + /// allowing it to differentiate between a user attempting a standard sign-in + /// and an anonymous user attempting to link their account. + final AuthFlow flow; + /// Creates a copy of the current [AuthenticationState] with updated values. AuthenticationState copyWith({ AuthenticationStatus? status, @@ -65,6 +90,7 @@ class AuthenticationState extends Equatable { HttpException? exception, DateTime? cooldownEndTime, bool clearCooldownEndTime = false, + AuthFlow? flow, }) { return AuthenticationState( status: status ?? this.status, @@ -74,9 +100,17 @@ class AuthenticationState extends Equatable { cooldownEndTime: clearCooldownEndTime ? null : cooldownEndTime ?? this.cooldownEndTime, + flow: flow ?? this.flow, ); } @override - List get props => [status, user, email, exception, cooldownEndTime]; + List get props => [ + status, + user, + email, + exception, + cooldownEndTime, + flow, + ]; } diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index 94a9ea9b..8917b57f 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -17,25 +17,7 @@ import 'package:ui_kit/ui_kit.dart'; /// {@endtemplate} class AuthenticationPage extends StatelessWidget { /// {@macro authentication_page} - const AuthenticationPage({ - required this.headline, - required this.subHeadline, - required this.showAnonymousButton, - required this.isLinkingContext, - super.key, - }); - - /// The main title displayed on the page. - final String headline; - - /// The descriptive text displayed below the headline. - final String subHeadline; - - /// Whether to show the "Continue Anonymously" button. - final bool showAnonymousButton; - - /// Whether this page is being shown in the account linking context. - final bool isLinkingContext; + const AuthenticationPage({super.key}); @override Widget build(BuildContext context) { @@ -47,17 +29,28 @@ class AuthenticationPage extends StatelessWidget { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, - // Conditionally add the leading close button only in linking context - leading: isLinkingContext + // Conditionally display the leading close button based on the authentication flow. + // It should only be visible when the user is in the account linking flow, + // allowing them to dismiss the authentication page and return to their account. + // For initial sign-in, there's no previous page to dismiss to. + leading: context.watch().state.flow == AuthFlow.linkAccount ? IconButton( icon: const Icon(Icons.close), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, onPressed: () { - // Navigate back to the account page when close is pressed + // When the authentication page is dismissed, reset the authentication + // flow in the BLoC. This ensures that if the user attempts to link + // their account again, the BlocListener in AccountPage will + // correctly re-trigger navigation, and the AuthenticationBloc + // is in a clean state for any future authentication attempts. + context.read().add( + const AuthenticationFlowReset(), + ); + // Navigate back to the account page. context.goNamed(Routes.accountName); }, ) - : null, + : null, // Hide the leading button if not in account linking flow. ), body: SafeArea( child: BlocConsumer( @@ -76,6 +69,24 @@ class AuthenticationPage extends StatelessWidget { builder: (context, state) { final isLoading = state.status == AuthenticationStatus.loading; + // Determine content based on the current authentication flow. + final String headline; + final String subHeadline; + final bool showAnonymousButton; + final IconData pageIcon; + + if (state.flow == AuthFlow.linkAccount) { + headline = l10n.authenticationLinkingHeadline; + subHeadline = l10n.authenticationLinkingSubheadline; + showAnonymousButton = false; + pageIcon = Icons.sync; + } else { + headline = l10n.authenticationSignInHeadline; + subHeadline = l10n.authenticationSignInSubheadline; + showAnonymousButton = true; + pageIcon = Icons.newspaper; + } + return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), child: Center( @@ -88,7 +99,7 @@ class AuthenticationPage extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: AppSpacing.xl), child: Icon( - isLinkingContext ? Icons.sync : Icons.newspaper, + pageIcon, size: AppSpacing.xxl * 2, color: colorScheme.primary, ), @@ -118,11 +129,10 @@ class AuthenticationPage extends StatelessWidget { onPressed: isLoading ? null : () { - context.goNamed( - isLinkingContext - ? Routes.linkingRequestCodeName - : Routes.requestCodeName, - ); + // Always navigate to the request code page. + // The behavior of the request code page will + // depend on the AuthenticationBloc's flow state. + context.goNamed(Routes.requestCodeName); }, label: Text(l10n.authenticationEmailSignInButton), style: ElevatedButton.styleFrom( diff --git a/lib/authentication/view/request_code_page.dart b/lib/authentication/view/request_code_page.dart index 792b3ca0..bd89be47 100644 --- a/lib/authentication/view/request_code_page.dart +++ b/lib/authentication/view/request_code_page.dart @@ -18,24 +18,17 @@ import 'package:ui_kit/ui_kit.dart'; /// {@endtemplate} class RequestCodePage extends StatelessWidget { /// {@macro request_code_page} - const RequestCodePage({required this.isLinkingContext, super.key}); - - /// Whether this page is being shown in the account linking context. - final bool isLinkingContext; + const RequestCodePage({super.key}); @override Widget build(BuildContext context) { // AuthenticationBloc is assumed to be provided by a parent route. - // Pass the linking context flag down to the view. - return _RequestCodeView(isLinkingContext: isLinkingContext); + return const _RequestCodeView(); } } class _RequestCodeView extends StatelessWidget { - // Accept the flag from the parent page. - const _RequestCodeView({required this.isLinkingContext}); - - final bool isLinkingContext; + const _RequestCodeView(); @override Widget build(BuildContext context) { @@ -51,19 +44,8 @@ class _RequestCodeView extends StatelessWidget { icon: const Icon(Icons.arrow_back), tooltip: MaterialLocalizations.of(context).backButtonTooltip, onPressed: () { - // Navigate back differently based on the context. - if (isLinkingContext) { - // If linking, go back to Auth page preserving the linking query param. - context.goNamed( - Routes.authenticationName, - queryParameters: isLinkingContext - ? {'context': 'linking'} - : const {}, - ); - } else { - // If normal sign-in, just go back to the Auth page. - context.goNamed(Routes.authenticationName); - } + // Navigate back to the Authentication page. + context.goNamed(Routes.authenticationName); }, ), ), @@ -83,9 +65,7 @@ class _RequestCodeView extends StatelessWidget { AuthenticationStatus.requestCodeSuccess) { // Navigate to the code verification page on success, passing the email context.pushNamed( - isLinkingContext - ? Routes.linkingVerifyCodeName - : Routes.verifyCodeName, + Routes.verifyCodeName, pathParameters: {'email': state.email!}, ); } diff --git a/lib/router/router.dart b/lib/router/router.dart index 3a2555aa..20a8923b 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -47,6 +47,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/settings/view/se import 'package:flutter_news_app_mobile_client_full_source_code/settings/view/theme_settings_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/feed_decorator_service.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; /// Creates and configures the GoRouter instance for the application. /// @@ -86,80 +87,92 @@ GoRouter createRouter({ // Add any other necessary observers here. If none, this can be an empty list. ], // --- Redirect Logic --- + // This function is the single source of truth for route protection. + // It's called by GoRouter on every navigation attempt. redirect: (BuildContext context, GoRouterState state) { + // Read the necessary states from the BLoCs. final appStatus = context.read().state.status; + final authFlow = context.read().state.flow; final currentLocation = state.matchedLocation; - print( - 'GoRouter Redirect Check:\n' - ' Current Location (Matched): $currentLocation\n' - ' AppStatus: $appStatus', - ); + // Enhanced logging for easier debugging of navigation flows. + final log = Logger('GoRouterRedirect') + ..info( + 'Redirect Check Triggered:\n' + ' -> Location: "${state.uri}" (Matched: "$currentLocation")\n' + ' -> App Status: $appStatus\n' + ' -> Auth Flow: $authFlow', + ); - const rootPath = '/'; const authenticationPath = Routes.authentication; const feedPath = Routes.feed; final isGoingToAuth = currentLocation.startsWith(authenticationPath); - // With the current 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. + // The app's root widget handles initial states like maintenance or + // critical errors before the router is even active. This redirect logic + // focuses purely on authentication-based route protection. // --- 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. + // An unauthenticated user must be directed to the authentication flow. if (appStatus == AppLifeCycleStatus.unauthenticated) { - print(' Redirect: User is unauthenticated.'); - // If they are already on an auth path, allow it. Otherwise, redirect. - return isGoingToAuth ? null : authenticationPath; + // If they are already on an authentication path, allow it. + if (isGoingToAuth) { + log.fine( + 'Decision: Allowing unauthenticated user to access auth route.', + ); + return null; + } + // Otherwise, redirect them to the main authentication page. + log.info( + 'Decision: Redirecting unauthenticated user to "$authenticationPath".', + ); + return authenticationPath; } // --- Case 2: Anonymous or Authenticated User --- - // 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. + // Users who are already logged in (either as anonymous or full users) + // have different access rules. if (appStatus == AppLifeCycleStatus.anonymous || appStatus == AppLifeCycleStatus.authenticated) { - print(' Redirect: User is $appStatus.'); - - // If the user is trying to access an authentication path: + // If a logged-in user tries to access an authentication path: if (isGoingToAuth) { - // A fully authenticated user should never see auth pages. + // A fully authenticated user should never see the sign-in pages. if (appStatus == AppLifeCycleStatus.authenticated) { - print( - ' Action: Authenticated user on auth path. Redirecting to feed.', + log.info( + 'Decision: Authenticated user on auth path. Redirecting to feed.', ); return feedPath; } - // 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.'); + // This is the critical gate that allows an anonymous user to access + // the authentication page *only if* they have explicitly initiated + // the account linking flow (AuthFlow.linkAccount). + // If the flow is not 'linkAccount', it means they are trying to + // access the auth page outside of the intended linking context, + // and should be redirected to the feed. + if (authFlow == AuthFlow.linkAccount) { + log.fine( + 'Decision: Allowing anonymous user in "linkAccount" flow.', + ); return null; } else { - print( - ' Action: Anonymous user on non-linking auth path. Redirecting to feed.', + log.info( + 'Decision: Anonymous user on auth path outside of linking flow. Redirecting to feed.', ); return feedPath; } } - // 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.'); + // If a logged-in user is at the root path, send them to the feed. + if (currentLocation == '/') { + log.info('Decision: User at root. Redirecting to feed.'); return feedPath; } } // --- Fallback --- - // For any other case, allow navigation. - print(' Redirect: No condition met. Allowing navigation.'); + // If no specific redirection rule was met, allow the navigation. + log.fine('Decision: No redirection condition met. Allowing navigation.'); return null; }, // --- Authentication Routes --- @@ -172,67 +185,22 @@ GoRouter createRouter({ path: Routes.authentication, name: Routes.authenticationName, builder: (BuildContext context, GoRouterState state) { - final l10n = context.l10n; - // Determine context from query parameter - final isLinkingContext = - state.uri.queryParameters['context'] == 'linking'; - - // Define content based on context - final String headline; - final String subHeadline; - final bool showAnonymousButton; - - if (isLinkingContext) { - headline = l10n.authenticationLinkingHeadline; - subHeadline = l10n.authenticationLinkingSubheadline; - showAnonymousButton = false; - } else { - headline = l10n.authenticationSignInHeadline; - subHeadline = l10n.authenticationSignInSubheadline; - showAnonymousButton = true; - } - + // The AuthenticationPage now gets its display context solely from + // the AuthenticationBloc state, removing the need for parameters. return BlocProvider( create: (context) => AuthenticationBloc( authenticationRepository: context.read(), ), - child: AuthenticationPage( - headline: headline, - subHeadline: subHeadline, - showAnonymousButton: showAnonymousButton, - isLinkingContext: isLinkingContext, - ), + child: const AuthenticationPage(), ); }, routes: [ - // Nested route for account linking flow (defined first for priority) - GoRoute( - path: Routes.accountLinking, - name: Routes.accountLinkingName, - builder: (context, state) => const SizedBox.shrink(), - routes: [ - GoRoute( - path: Routes.requestCode, - name: Routes.linkingRequestCodeName, - builder: (context, state) => - const RequestCodePage(isLinkingContext: true), - ), - GoRoute( - path: '${Routes.verifyCode}/:email', - name: Routes.linkingVerifyCodeName, - builder: (context, state) { - final email = state.pathParameters['email']!; - return EmailCodeVerificationPage(email: email); - }, - ), - ], - ), - // Non-linking authentication routes (defined after linking routes) + // These routes are now used for both standard sign-in and account + // linking, with the UI adapting based on the BLoC's `AuthFlow` state. GoRoute( path: Routes.requestCode, name: Routes.requestCodeName, - builder: (context, state) => - const RequestCodePage(isLinkingContext: false), + builder: (context, state) => const RequestCodePage(), ), GoRoute( path: '${Routes.verifyCode}/:email', diff --git a/lib/router/routes.dart b/lib/router/routes.dart index ad9cb469..a5e063b4 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -50,8 +50,6 @@ abstract final class Routes { static const resetPasswordName = 'resetPassword'; static const confirmEmail = 'confirm-email'; static const confirmEmailName = 'confirmEmail'; - static const accountLinking = 'linking'; - static const accountLinkingName = 'accountLinking'; // routes for email code verification flow static const requestCode = 'request-code'; @@ -59,12 +57,6 @@ abstract final class Routes { static const verifyCode = 'verify-code'; static const verifyCodeName = 'verifyCode'; - // Linking-specific authentication routes - static const linkingRequestCode = 'linking/request-code'; - static const linkingRequestCodeName = 'linkingRequestCode'; - static const linkingVerifyCode = 'linking/verify-code'; - static const linkingVerifyCodeName = 'linkingVerifyCode'; - // --- Settings Sub-Routes (relative to /account/settings) --- static const settingsAppearance = 'appearance'; static const settingsAppearanceName = 'settingsAppearance'; diff --git a/lib/shared/services/feed_decorator_service.dart b/lib/shared/services/feed_decorator_service.dart index 55d7ab8d..b9395f00 100644 --- a/lib/shared/services/feed_decorator_service.dart +++ b/lib/shared/services/feed_decorator_service.dart @@ -298,7 +298,7 @@ class FeedDecoratorService { description: 'Save your preferences and followed items by creating a free account.', ctaText: 'Get Started', - ctaUrl: '${Routes.authentication}/${Routes.accountLinking}', + ctaUrl: Routes.authentication, ), FeedDecoratorType.upgrade: ( title: 'Upgrade to Premium', diff --git a/lib/status/view/update_required_page.dart b/lib/status/view/update_required_page.dart index ea0eb886..8ff16c86 100644 --- a/lib/status/view/update_required_page.dart +++ b/lib/status/view/update_required_page.dart @@ -62,7 +62,8 @@ class UpdateRequiredPage extends StatelessWidget { style: theme.textTheme.bodyLarge, textAlign: TextAlign.center, ), - if (currentAppVersion != null && latestRequiredVersion != null) ...[ + if (currentAppVersion != null && + latestRequiredVersion != null) ...[ const SizedBox(height: AppSpacing.md), Text( l10n.currentAppVersionLabel(currentAppVersion!),