Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ddeeec7
feat(authentication): refactor account linking and route protection
fulleni Oct 6, 2025
7da9dab
refactor(account): update navigation to use specific route name
fulleni Oct 6, 2025
3b1595b
refactor(authentication): simplify AuthenticationPage for new users
fulleni Oct 6, 2025
0bcaa26
refactor(authentication): simplify request code page navigation
fulleni Oct 6, 2025
ee612c6
feat(authentication): add account linking page for anonymous users
fulleni Oct 6, 2025
3488a31
docs(router): add comments explaining top-level routes and authentica…
fulleni Oct 6, 2025
3fedb86
feat(authentication): implement static content using localization
fulleni Oct 6, 2025
997c788
fix(authentication): navigate to correct verification page based on c…
fulleni Oct 6, 2025
3cbda23
feat(router): update account linking page transition
fulleni Oct 6, 2025
27635c2
refactor(authentication): convert AccountLinkingPage to modal bottom …
fulleni Oct 6, 2025
c0c94e0
refactor(authentication): make AccountLinkingPage dismissible
fulleni Oct 6, 2025
ceaae82
fix(account): update button navigation to use push instead of go
fulleni Oct 6, 2025
ed39e56
refactor(authentication): convert AccountLinkingPage to full-screen view
fulleni Oct 6, 2025
0284c9c
refactor(router): simplify AccountLinking page transition
fulleni Oct 6, 2025
aca2b62
feat(l10n): update account linking headline text in app_ar.arb and ap…
fulleni Oct 6, 2025
5f94d48
style: format
fulleni Oct 6, 2025
4fa036f
fix(l10n): update translations and remove account linking reference
fulleni Oct 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions lib/account/view/account_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,7 @@ class AccountPage extends StatelessWidget {
textStyle: textTheme.labelLarge,
),
onPressed: () {
context.goNamed(
Routes.authenticationName,
queryParameters: {'context': 'linking'},
);
context.pushNamed(Routes.accountLinkingName);
},
),
);
Expand Down
105 changes: 105 additions & 0 deletions lib/authentication/view/account_linking_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_news_app_mobile_client_full_source_code/authentication/bloc/authentication_bloc.dart';
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:ui_kit/ui_kit.dart';

/// {@template account_linking_page}
/// Displays options for an anonymous user to link their account to an email.
///
/// {@endtemplate}
class AccountLinkingPage extends StatelessWidget {
/// {@macro account_linking_page}
const AccountLinkingPage({super.key});

@override
Widget build(BuildContext context) {
final l10n = AppLocalizationsX(context).l10n;
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;

return Scaffold(
appBar: AppBar(title: Text(l10n.accountLinkingHeadline)),
body: BlocConsumer<AuthenticationBloc, AuthenticationState>(
listener: (context, state) {
if (state.status == AuthenticationStatus.failure) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(state.exception!.toFriendlyMessage(context)),
backgroundColor: colorScheme.error,
),
);
}
},
builder: (context, state) {
final isLoading = state.status == AuthenticationStatus.loading;

return Padding(
padding: const EdgeInsets.all(AppSpacing.paddingLarge),
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
child: Icon(
Icons.sync,
size: AppSpacing.xxl * 2,
color: colorScheme.primary,
),
),
Text(
l10n.accountLinkingHeadline,
style: textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.accountLinkingBody,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.xxl),
ElevatedButton.icon(
icon: const Icon(Icons.email_outlined),
onPressed: isLoading
? null
: () {
context.goNamed(
Routes.accountLinkingRequestCodeName,
);
},
label: Text(l10n.accountLinkingSendLinkButton),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.md,
),
textStyle: textTheme.labelLarge,
),
),
if (isLoading) ...[
const Padding(
padding: EdgeInsets.only(top: AppSpacing.xl),
child: Center(child: CircularProgressIndicator()),
),
],
],
),
),
),
);
},
),
);
}
}
83 changes: 22 additions & 61 deletions lib/authentication/view/authentication_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,13 @@ import 'package:go_router/go_router.dart';
import 'package:ui_kit/ui_kit.dart';

/// {@template authentication_page}
/// Displays authentication options (Google, Email, Anonymous) based on context.
/// Displays authentication options (Email, Anonymous) for new users.
///
/// This page can be used for both initial sign-in and for connecting an
/// existing anonymous account.
/// This page is exclusively for initial sign-in/sign-up.
/// {@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) {
Expand All @@ -44,21 +25,7 @@ class AuthenticationPage extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;

return Scaffold(
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,
),
appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0),
body: SafeArea(
child: BlocConsumer<AuthenticationBloc, AuthenticationState>(
listener: (context, state) {
Expand Down Expand Up @@ -88,23 +55,23 @@ class AuthenticationPage extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
child: Icon(
isLinkingContext ? Icons.sync : Icons.newspaper,
Icons.newspaper,
size: AppSpacing.xxl * 2,
color: colorScheme.primary,
),
),
// const SizedBox(height: AppSpacing.lg),
// --- Headline and Subheadline ---
Text(
headline,
l10n.authenticationSignInHeadline,
style: textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.md),
Text(
subHeadline,
l10n.authenticationSignInSubheadline,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
Expand All @@ -118,11 +85,7 @@ class AuthenticationPage extends StatelessWidget {
onPressed: isLoading
? null
: () {
context.goNamed(
isLinkingContext
? Routes.linkingRequestCodeName
: Routes.requestCodeName,
);
context.goNamed(Routes.requestCodeName);
},
label: Text(l10n.authenticationEmailSignInButton),
style: ElevatedButton.styleFrom(
Expand All @@ -134,24 +97,22 @@ class AuthenticationPage extends StatelessWidget {
),
const SizedBox(height: AppSpacing.lg),

// --- Anonymous Sign-In Button (Conditional) ---
if (showAnonymousButton) ...[
OutlinedButton.icon(
icon: const Icon(Icons.person_outline),
onPressed: isLoading
? null
: () => context.read<AuthenticationBloc>().add(
const AuthenticationAnonymousSignInRequested(),
),
label: Text(l10n.authenticationAnonymousSignInButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.md,
),
textStyle: textTheme.labelLarge,
// --- Anonymous Sign-In Button ---
OutlinedButton.icon(
icon: const Icon(Icons.person_outline),
onPressed: isLoading
? null
: () => context.read<AuthenticationBloc>().add(
const AuthenticationAnonymousSignInRequested(),
),
label: Text(l10n.authenticationAnonymousSignInButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.md,
),
textStyle: textTheme.labelLarge,
),
],
),

// --- Loading Indicator ---
if (isLoading) ...[
Expand Down
81 changes: 24 additions & 57 deletions lib/authentication/view/request_code_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -51,19 +44,10 @@ 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 previous page in the stack.
// GoRouter will handle the correct navigation based on the current
// route's parent.
context.pop();
},
),
),
Expand All @@ -81,13 +65,23 @@ class _RequestCodeView extends StatelessWidget {
);
} else if (state.status ==
AuthenticationStatus.requestCodeSuccess) {
// Navigate to the code verification page on success, passing the email
context.pushNamed(
isLinkingContext
? Routes.linkingVerifyCodeName
: Routes.verifyCodeName,
pathParameters: {'email': state.email!},
);
// Navigate to the code verification page on success, passing the email.
// The current route's parent will determine if this is for linking
// or standard authentication.
final currentRouteName = GoRouter.of(
context,
).routerDelegate.currentConfiguration.last.route.name;
if (currentRouteName == Routes.accountLinkingRequestCodeName) {
context.goNamed(
Routes.accountLinkingVerifyCodeName,
pathParameters: {'email': state.email!},
);
} else {
context.goNamed(
Routes.verifyCodeName,
pathParameters: {'email': state.email!},
);
}
}
},
// BuildWhen prevents unnecessary rebuilds if only listening
Expand Down Expand Up @@ -130,33 +124,6 @@ class _RequestCodeView extends StatelessWidget {
),
textAlign: TextAlign.center,
),

// NOT NEEDED; any email is accepted in demo mode
//
//Display demo email suggestion if in demo environment
// BlocSelector<AppBloc, AppState, AppEnvironment?>(
// selector: (state) => state.environment,
// builder: (context, environment) {
// if (environment == AppEnvironment.demo) {
// return Column(
// children: [
// const SizedBox(height: AppSpacing.md),
// Text(
// l10n.demoEmailSuggestionMessage(
// 'admin@mail.com',
// ),
// style: textTheme.bodyMedium?.copyWith(
// color: colorScheme.secondary,
// fontWeight: FontWeight.bold,
// ),
// textAlign: TextAlign.center,
// ),
// ],
// );
// }
// return const SizedBox.shrink();
// },
// ),
const SizedBox(height: AppSpacing.xxl),
_EmailLinkForm(isLoading: isLoading),
],
Expand Down
4 changes: 2 additions & 2 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,13 @@ abstract class AppLocalizations {
/// Headline text on the account linking page
///
/// In en, this message translates to:
/// **'Create or Link Account to Save Progress'**
/// **'Save your progress'**
String get accountLinkingHeadline;

/// Body text explaining the benefits of linking an account
///
/// In en, this message translates to:
/// **'Signing up or linking allows you to access your information across multiple devices and ensures your progress isn\'t lost.'**
/// **'Signing up allows you to access your information across multiple devices and ensures your progress isn\'t lost.'**
String get accountLinkingBody;

/// Text for the Google sign-in button
Expand Down
Loading
Loading