From 66d6e9861f66cb8cccc3847dc3e486392058be6b Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:43:21 +0100 Subject: [PATCH 01/14] feat(app_state): add postAuthRedirectIntent property to AppState - Add new property 'postAuthRedirectIntent' to store intended navigation path after authentication - Update AppState copyWith method to handle new property - Include new property in AppState props for equality checks - Add comments to explain the purpose and usage of the new property --- lib/app/bloc/app_state.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 50e68b78..773aab14 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -49,6 +49,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 +91,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 +170,7 @@ class AppState extends Equatable { selectedBottomNavigationIndex, environment, currentAppVersion, + postAuthRedirectIntent, // Include the new property in props ]; /// Creates a copy of this [AppState] with the given fields replaced with @@ -178,6 +187,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 +205,9 @@ class AppState extends Equatable { selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex, environment: environment ?? this.environment, currentAppVersion: currentAppVersion ?? this.currentAppVersion, + postAuthRedirectIntent: clearPostAuthRedirectIntent + ? null + : postAuthRedirectIntent ?? this.postAuthRedirectIntent, ); } } From 04226ea84a9d7b17989c34f6b480178cf2e7a700 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:43:53 +0100 Subject: [PATCH 02/14] feat(app): add post-auth redirect intent captured event - Add new AppEvent for capturing navigation intents before authentication - This event will help track where users should be redirected after successful auth - Include GoRouterState as a property to represent the intended destination --- lib/app/bloc/app_event.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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]; +} From 71d05862fcf8673afb198224f2401b8f2dae8c0c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:44:23 +0100 Subject: [PATCH 03/14] feat(auth): implement post-authentication redirect functionality - Add handling for PostAuthRedirectIntentCaptured events in AppBloc - Implement navigation to pending intent after user data is loaded - Store and clear post-auth redirect intent in AppState - Add GoRouter import for context navigation --- lib/app/bloc/app_bloc.dart | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index f0972f6b..bcf0eeda 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'; // Added import for GoRouterState 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)); + } } From cfc120470cd87c99017f38abad21ed951abf9f9a Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:44:31 +0100 Subject: [PATCH 04/14] feat(authentication): add account linking flow support - Introduce AuthFlow enum to differentiate between standard sign-in and account linking flows - Add flow property to AuthenticationState with default value of signIn - Update copyWith method to include flow parameter - Modify props list to include the new flow property --- .../bloc/authentication_state.dart | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/authentication/bloc/authentication_state.dart b/lib/authentication/bloc/authentication_state.dart index 5664a91a..26622550 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -24,6 +24,15 @@ enum AuthenticationStatus { failure, } +/// Defines the different authentication flows the user might be in. +enum AuthFlow { + /// Standard sign-in/sign-up flow. + signIn, + + /// Account linking flow for an existing anonymous user. + linkAccount, +} + /// {@template authentication_state} /// Represents the state of the authentication process. /// @@ -40,6 +49,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 +68,9 @@ 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). + final AuthFlow flow; + /// Creates a copy of the current [AuthenticationState] with updated values. AuthenticationState copyWith({ AuthenticationStatus? status, @@ -65,6 +79,7 @@ class AuthenticationState extends Equatable { HttpException? exception, DateTime? cooldownEndTime, bool clearCooldownEndTime = false, + AuthFlow? flow, }) { return AuthenticationState( status: status ?? this.status, @@ -74,9 +89,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, // Include the new flow property in props + ]; } From 6113f2eaae08789543819842733530b4944f42f5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:44:41 +0100 Subject: [PATCH 05/14] feat(authentication): add AuthenticationLinkingInitiated event Add a new event to represent when an anonymous user initiates the account linking flow. --- lib/authentication/bloc/authentication_event.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/authentication/bloc/authentication_event.dart b/lib/authentication/bloc/authentication_event.dart index a667174c..474244bf 100644 --- a/lib/authentication/bloc/authentication_event.dart +++ b/lib/authentication/bloc/authentication_event.dart @@ -84,3 +84,11 @@ 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. +/// {@endtemplate} +final class AuthenticationLinkingInitiated extends AuthenticationEvent { + /// {@macro authentication_linking_initiated} + const AuthenticationLinkingInitiated(); +} From 7d2a12786970ed1446bc13b6f4e6b6c8ce193dda Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:44:58 +0100 Subject: [PATCH 06/14] feat(authentication): implement account linking flow - Add AuthenticationLinkingInitiated event and handler - Update authentication flow logic for signing in and linking - Reset authentication flow to signIn after sign-out or linking --- .../bloc/authentication_bloc.dart | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index d307ca84..433aca41 100644 --- a/lib/authentication/bloc/authentication_bloc.dart +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -34,6 +34,7 @@ class AuthenticationBloc ); on(_onAuthenticationSignOutRequested); on(_onAuthenticationCooldownCompleted); + on(_onAuthenticationLinkingInitiated); } final AuthRepository _authenticationRepository; @@ -41,6 +42,9 @@ class AuthenticationBloc 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, @@ -50,6 +54,11 @@ class AuthenticationBloc 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, ), ); } else { @@ -57,6 +66,8 @@ class AuthenticationBloc state.copyWith( status: AuthenticationStatus.unauthenticated, user: null, + // When a user logs out, reset the flow to standard sign-in. + flow: AuthFlow.signIn, ), ); } @@ -146,6 +157,8 @@ class AuthenticationBloc } /// Handles [AuthenticationSignOutRequested] events. + /// + /// Resets the authentication flow to `signIn` upon sign-out. Future _onAuthenticationSignOutRequested( AuthenticationSignOutRequested event, Emitter emit, @@ -155,6 +168,8 @@ class AuthenticationBloc 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)); } on HttpException catch (e) { emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { @@ -185,4 +200,14 @@ class AuthenticationBloc ), ); } + + /// Handles [AuthenticationLinkingInitiated] events. + /// + /// Sets the authentication flow to `linkAccount`. + void _onAuthenticationLinkingInitiated( + AuthenticationLinkingInitiated event, + Emitter emit, + ) { + emit(state.copyWith(flow: AuthFlow.linkAccount)); + } } From aeef9172fa04e703cf78f94fbcb09a24400c552d Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:45:35 +0100 Subject: [PATCH 07/14] feat(authentication): simplify AuthenticationPage and improve flow handling - Remove unnecessary parameters (headline, subHeadline, showAnonymousButton, isLinkingContext) from AuthenticationPage constructor - Implement dynamic content rendering based on AuthenticationBloc's flow state - Refactor leading IconButton to always show without condition - Update icon display based on authentication flow - Simplify sign-in button navigation by removing isLinkingContext check --- .../view/authentication_page.dart | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index 94a9ea9b..b65b4c67 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -18,25 +18,9 @@ import 'package:ui_kit/ui_kit.dart'; 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; - @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -47,17 +31,14 @@ class AuthenticationPage extends StatelessWidget { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, - // Conditionally add the leading close button only in linking context - leading: isLinkingContext - ? IconButton( - icon: const Icon(Icons.close), - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () { - // Navigate back to the account page when close is pressed - context.goNamed(Routes.accountName); - }, - ) - : null, + leading: IconButton( + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () { + // Navigate back to the account page when close is pressed + context.goNamed(Routes.accountName); + }, + ), ), body: SafeArea( child: BlocConsumer( @@ -76,6 +57,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 +87,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 +117,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( From d3c9d271dc09ae2dddc0ff2b8a3f94a28e58ffa6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:45:58 +0100 Subject: [PATCH 08/14] refactor(authentication): remove account linking context handling - Remove isLinkingContext parameter from RequestCodePage - Remove context-sensitive navigation logic from _RequestCodeView - Simplify back navigation to always go to Authentication page - Remove conditional routing for code verification --- .../view/request_code_page.dart | 32 ++++--------------- 1 file changed, 6 insertions(+), 26 deletions(-) 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!}, ); } From c6e709b6bb2ecc35965ae0ac5054da3cb6a406cc Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:46:07 +0100 Subject: [PATCH 09/14] feat(account): initiate linking process from account page - Remove query parameter 'context' as it's no longer needed - Add AuthenticationLinkingInitiated event to kick off linking process - Simplify navigation to authentication page --- lib/account/view/account_page.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index a6241331..d4f4bff8 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -113,10 +113,10 @@ class AccountPage extends StatelessWidget { textStyle: textTheme.labelLarge, ), onPressed: () { - context.goNamed( - Routes.authenticationName, - queryParameters: {'context': 'linking'}, - ); + context + .read() + .add(const AuthenticationLinkingInitiated()); + context.goNamed(Routes.authenticationName); }, ), ); From a8eb1cd1c71ad5ce1a0f65b58a306ea9618791c7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:46:20 +0100 Subject: [PATCH 10/14] refactor(router): simplify authentication flow and improve logging - Remove nested account linking routes and related parameters - Enhance redirect logic with detailed logging using logging package - Adjust authentication page to use BLoC state for display context - Simplify route protection logic and remove unnecessary comments --- lib/router/router.dart | 135 ++++++++++++++--------------------------- lib/router/routes.dart | 8 --- 2 files changed, 45 insertions(+), 98 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 3a2555aa..994ef6af 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,78 @@ 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.'); + // An anonymous user is only allowed on auth paths if they are in + // the 'linkAccount' flow. This is now the single source of truth. + 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 +171,23 @@ 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), + 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'; From b28d7a49916eb48ad6bc77aed8fe974734f23c1c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 3 Oct 2025 10:48:44 +0100 Subject: [PATCH 11/14] style: format & cleanup --- lib/account/view/account_page.dart | 6 ++-- lib/app/bloc/app_bloc.dart | 2 +- lib/app/bloc/app_state.dart | 2 +- .../bloc/authentication_state.dart | 2 +- .../view/authentication_page.dart | 4 +-- lib/router/router.dart | 35 ++++++++++++------- .../services/feed_decorator_service.dart | 2 +- lib/status/view/update_required_page.dart | 3 +- 8 files changed, 32 insertions(+), 24 deletions(-) diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index d4f4bff8..21e5ba70 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -113,9 +113,9 @@ class AccountPage extends StatelessWidget { textStyle: textTheme.labelLarge, ), onPressed: () { - context - .read() - .add(const AuthenticationLinkingInitiated()); + context.read().add( + const AuthenticationLinkingInitiated(), + ); context.goNamed(Routes.authenticationName); }, ), diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index bcf0eeda..c1cd913f 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -12,7 +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'; // Added import for GoRouterState +import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:pub_semver/pub_semver.dart'; diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 773aab14..908c658f 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -170,7 +170,7 @@ class AppState extends Equatable { selectedBottomNavigationIndex, environment, currentAppVersion, - postAuthRedirectIntent, // Include the new property in props + postAuthRedirectIntent, ]; /// Creates a copy of this [AppState] with the given fields replaced with diff --git a/lib/authentication/bloc/authentication_state.dart b/lib/authentication/bloc/authentication_state.dart index 26622550..16e860e9 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -100,6 +100,6 @@ class AuthenticationState extends Equatable { email, exception, cooldownEndTime, - flow, // Include the new flow property in props + flow, ]; } diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index b65b4c67..a768c5ec 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -17,9 +17,7 @@ import 'package:ui_kit/ui_kit.dart'; /// {@endtemplate} class AuthenticationPage extends StatelessWidget { /// {@macro authentication_page} - const AuthenticationPage({ - super.key, - }); + const AuthenticationPage({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/router/router.dart b/lib/router/router.dart index 994ef6af..130168b3 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -97,12 +97,12 @@ GoRouter createRouter({ // 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', - ); + ..info( + 'Redirect Check Triggered:\n' + ' -> Location: "${state.uri}" (Matched: "$currentLocation")\n' + ' -> App Status: $appStatus\n' + ' -> Auth Flow: $authFlow', + ); const authenticationPath = Routes.authentication; const feedPath = Routes.feed; @@ -117,11 +117,15 @@ GoRouter createRouter({ if (appStatus == AppLifeCycleStatus.unauthenticated) { // If they are already on an authentication path, allow it. if (isGoingToAuth) { - log.fine('Decision: Allowing unauthenticated user to access auth route.'); + 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".'); + log.info( + 'Decision: Redirecting unauthenticated user to "$authenticationPath".', + ); return authenticationPath; } @@ -134,17 +138,23 @@ GoRouter createRouter({ if (isGoingToAuth) { // A fully authenticated user should never see the sign-in pages. if (appStatus == AppLifeCycleStatus.authenticated) { - log.info('Decision: 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 if they are in // the 'linkAccount' flow. This is now the single source of truth. if (authFlow == AuthFlow.linkAccount) { - log.fine('Decision: Allowing anonymous user in "linkAccount" flow.'); + log.fine( + 'Decision: Allowing anonymous user in "linkAccount" flow.', + ); return null; } else { - log.info('Decision: Anonymous user on auth path outside of linking flow. Redirecting to feed.'); + log.info( + 'Decision: Anonymous user on auth path outside of linking flow. Redirecting to feed.', + ); return feedPath; } } @@ -186,8 +196,7 @@ GoRouter createRouter({ GoRoute( path: Routes.requestCode, name: Routes.requestCodeName, - builder: (context, state) => - const RequestCodePage(), + builder: (context, state) => const RequestCodePage(), ), GoRoute( path: '${Routes.verifyCode}/:email', 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!), From b67e3760d68b54fda51595c73161f1458363ee35 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 5 Oct 2025 06:28:42 +0100 Subject: [PATCH 12/14] feat(account): implement logging and improve authentication flow - Add Logger instance for AccountPage - Implement logging for account linking and sign-out actions - Replace BlocListener with context.pushNamed for authentication navigation - Update button actions with appropriate logging and navigation --- lib/account/view/account_page.dart | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index 21e5ba70..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: () { + _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(), ); - context.goNamed(Routes.authenticationName); + // 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(), ); From a714d217c4fc0bf0ec4c564b09ba358fd622b185 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 5 Oct 2025 06:29:01 +0100 Subject: [PATCH 13/14] refactor(app): enhance clarity of AppLifeCycleStatus enum comments - Add detailed comments to unauthenticated, authenticated, and anonymous states - Clarify the nature of user sessions in each state - Improve understanding of user session types and their implications --- lib/app/bloc/app_state.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 908c658f..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, From 06413712a28c79f1c15d4b0e59824e9dc8a9362b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 5 Oct 2025 06:29:22 +0100 Subject: [PATCH 14/14] feat(authentication): enhance AuthenticationBloc with logging and flow reset - Add comprehensive logging to AuthenticationBloc for better debugging - Implement AuthenticationFlowReset event to revert auth flow to signIn - Enhance AuthenticationLinkingInitiated event documentation - Improve AuthFlow enum descriptions - Conditionally display close button in AuthenticationPage for account linking flow - Strengthen router redirect logic for anonymous users --- .../bloc/authentication_bloc.dart | 158 +++++++++++++++++- .../bloc/authentication_event.dart | 20 +++ .../bloc/authentication_state.dart | 15 +- .../view/authentication_page.dart | 30 +++- lib/router/router.dart | 8 +- 5 files changed, 218 insertions(+), 13 deletions(-) diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index 433aca41..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( @@ -35,9 +37,11 @@ class AuthenticationBloc on(_onAuthenticationSignOutRequested); on(_onAuthenticationCooldownCompleted); on(_onAuthenticationLinkingInitiated); + on(_onAuthenticationFlowReset); } final AuthRepository _authenticationRepository; + final Logger _logger; // Declare logger late final StreamSubscription _userAuthSubscription; Timer? _cooldownTimer; @@ -49,6 +53,12 @@ class AuthenticationBloc _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( @@ -61,6 +71,10 @@ class AuthenticationBloc flow: AuthFlow.signIn, ), ); + _logger.info( + '[_onAuthenticationUserChanged] User authenticated. ' + 'New state status: ${state.status}, New AuthFlow: ${state.flow}.', + ); } else { emit( state.copyWith( @@ -70,6 +84,10 @@ class AuthenticationBloc flow: AuthFlow.signIn, ), ); + _logger.info( + '[_onAuthenticationUserChanged] User unauthenticated. ' + 'New state status: ${state.status}, New AuthFlow: ${state.flow}.', + ); } } @@ -78,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); @@ -94,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( @@ -101,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, @@ -117,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, @@ -139,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, @@ -163,16 +259,38 @@ class AuthenticationBloc 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, @@ -193,21 +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`. + /// 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 474244bf..0b131e5a 100644 --- a/lib/authentication/bloc/authentication_event.dart +++ b/lib/authentication/bloc/authentication_event.dart @@ -87,8 +87,28 @@ final class AuthenticationCooldownCompleted extends AuthenticationEvent { /// {@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 16e860e9..2f0b064e 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -25,11 +25,18 @@ enum AuthenticationStatus { } /// 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. + /// 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 for an existing anonymous user. + /// Account linking flow, specifically for an anonymous user who wishes to + /// associate their current anonymous session with a permanent email account. linkAccount, } @@ -69,6 +76,10 @@ class AuthenticationState extends Equatable { 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. diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index a768c5ec..8917b57f 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -29,14 +29,28 @@ class AuthenticationPage extends StatelessWidget { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, - leading: IconButton( - icon: const Icon(Icons.close), - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () { - // Navigate back to the account page when close is pressed - context.goNamed(Routes.accountName); - }, - ), + // 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: () { + // 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, // Hide the leading button if not in account linking flow. ), body: SafeArea( child: BlocConsumer( diff --git a/lib/router/router.dart b/lib/router/router.dart index 130168b3..20a8923b 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -144,8 +144,12 @@ GoRouter createRouter({ return feedPath; } - // An anonymous user is only allowed on auth paths if they are in - // the 'linkAccount' flow. This is now the single source of truth. + // 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.',