Email Confirmation and refactor for snackbar

This commit is contained in:
Arseni
2026-01-27 14:42:52 +03:00
parent e5cd0c9433
commit be1d678c42
28 changed files with 958 additions and 173 deletions

View File

@@ -22,7 +22,9 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
final String? initialEmail;
const LoginForm({super.key, this.initialEmail});
@override
State<LoginForm> createState() => _LoginFormState();
@@ -37,6 +39,16 @@ class _LoginFormState extends State<LoginForm> {
final ValueNotifier<bool> _isUsernameAcceptable = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isPasswordAcceptable = ValueNotifier<bool>(false);
@override
void initState() {
super.initState();
final initialEmail = widget.initialEmail?.trim();
if (initialEmail != null && initialEmail.isNotEmpty) {
_usernameController.text = initialEmail;
_isUsernameAcceptable.value = true;
}
}
Future<String?> _login(BuildContext context, VoidCallback onLogin, void Function(Object e) onError) async {
final provider = Provider.of<AccountProvider>(context, listen: false);

View File

@@ -91,7 +91,7 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
));
} catch (e) {
notifyUserOfErrorX(
scaffoldMessenger: sms,
context: context,
errorSituation: widget.delegate.errorSituation,
appLocalizations: locs,
exception: e,

View File

@@ -42,7 +42,6 @@ class ImageTile extends AbstractSettingsTile {
Future<void> _pickImage(BuildContext context) async {
final picker = ImagePicker();
final locs = AppLocalizations.of(context)!;
final sm = ScaffoldMessenger.of(context);
final picked = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: maxWidth,
@@ -57,9 +56,9 @@ class ImageTile extends AbstractSettingsTile {
}
} catch (e) {
notifyUserOfErrorX(
scaffoldMessenger: sm,
errorSituation: imageUpdateError ?? locs.settingsImageUpdateError,
exception: e,
context: context,
errorSituation: imageUpdateError ?? locs.settingsImageUpdateError,
exception: e,
appLocalizations: locs,
);
}

View File

@@ -0,0 +1,5 @@
class SignupConfirmationArgs {
final String? email;
const SignupConfirmationArgs({this.email});
}

View File

@@ -0,0 +1,39 @@
part of 'card.dart';
class _SignupConfirmationEmailBadge extends StatelessWidget {
final String email;
const _SignupConfirmationEmailBadge({required this.email});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.alternate_email,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
SelectableText(
email,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/vspacer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
part 'state.dart';
part 'content.dart';
part 'badge.dart';
part 'resend_button.dart';
class SignupConfirmationCard extends StatefulWidget {
final String? email;
final bool isEmbedded;
const SignupConfirmationCard({
super.key,
this.email,
this.isEmbedded = false,
});
@override
State<SignupConfirmationCard> createState() => _SignupConfirmationCardState();
}

View File

@@ -0,0 +1,77 @@
part of 'card.dart';
class _SignupConfirmationContent extends StatelessWidget {
final String? email;
final String description;
final bool canResend;
final String resendLabel;
final bool isResending;
final VoidCallback onResend;
const _SignupConfirmationContent({
required this.email,
required this.description,
required this.canResend,
required this.resendLabel,
required this.isResending,
required this.onResend,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final locs = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.mark_email_read_outlined,
color: colorScheme.primary,
),
),
const VSpacer(),
Text(
locs.signupConfirmationTitle,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const VSpacer(),
Text(
description,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
if (email != null && email!.isNotEmpty) ...[
const VSpacer(),
_SignupConfirmationEmailBadge(email: email!),
],
const VSpacer(multiplier: 1.5),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
_SignupConfirmationResendButton(
canResend: canResend,
isResending: isResending,
resendLabel: resendLabel,
onResend: onResend,
),
],
),
],
);
}
}

View File

@@ -0,0 +1,36 @@
part of 'card.dart';
class _SignupConfirmationResendButton extends StatelessWidget {
final bool canResend;
final bool isResending;
final String resendLabel;
final VoidCallback onResend;
const _SignupConfirmationResendButton({
required this.canResend,
required this.isResending,
required this.resendLabel,
required this.onResend,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ElevatedButton.icon(
onPressed: canResend ? onResend : null,
icon: isResending
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.mark_email_read_outlined),
label: Text(resendLabel),
);
}
}

View File

@@ -0,0 +1,122 @@
part of 'card.dart';
class _SignupConfirmationCardState extends State<SignupConfirmationCard> {
static const int _defaultCooldownSeconds = 60;
Timer? _cooldownTimer;
int _cooldownRemainingSeconds = 0;
bool _isResending = false;
@override
void initState() {
super.initState();
_startCooldown(_defaultCooldownSeconds);
}
@override
void dispose() {
_cooldownTimer?.cancel();
super.dispose();
}
bool get _isCooldownActive => _cooldownRemainingSeconds > 0;
void _startCooldown(int seconds) {
_cooldownTimer?.cancel();
if (seconds <= 0) {
setState(() => _cooldownRemainingSeconds = 0);
return;
}
setState(() => _cooldownRemainingSeconds = seconds);
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_cooldownRemainingSeconds <= 1) {
timer.cancel();
setState(() => _cooldownRemainingSeconds = 0);
return;
}
setState(() => _cooldownRemainingSeconds -= 1);
});
}
Future<void> _resendVerificationEmail() async {
final email = widget.email?.trim();
final locs = AppLocalizations.of(context)!;
if (email == null || email.isEmpty) {
notifyUser(context, locs.errorEmailMissing);
return;
}
if (_isResending || _isCooldownActive) return;
setState(() => _isResending = true);
try {
await context.read<AccountProvider>().resendVerificationEmail(email);
if (!mounted) return;
notifyUser(context, locs.signupConfirmationResent(email));
_startCooldown(_defaultCooldownSeconds);
} catch (e) {
if (!mounted) return;
postNotifyUserOfErrorX(
context: context,
errorSituation: locs.signupConfirmationResendError,
exception: e,
);
} finally {
if (mounted) {
setState(() => _isResending = false);
}
}
}
String _formatCooldown(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
if (minutes > 0) {
return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}';
}
return remainingSeconds.toString();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locs = AppLocalizations.of(context)!;
final email = widget.email?.trim();
final description = (email != null && email.isNotEmpty)
? locs.signupConfirmationDescription(email)
: locs.signupConfirmationDescriptionNoEmail;
final canResend = !_isResending && !_isCooldownActive && email != null && email.isNotEmpty;
final resendLabel = _isCooldownActive
? locs.signupConfirmationResendCooldown(_formatCooldown(_cooldownRemainingSeconds))
: locs.signupConfirmationResend;
final content = _SignupConfirmationContent(
email: email,
description: description,
canResend: canResend,
resendLabel: resendLabel,
isResending: _isResending,
onResend: _resendVerificationEmail,
);
if (widget.isEmbedded) return content;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.6),
),
),
child: Padding(
padding: const EdgeInsets.all(28),
child: content,
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/widgets/vspacer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SignupConfirmationLoginPrompt extends StatelessWidget {
final bool isEmbedded;
const SignupConfirmationLoginPrompt({
super.key,
this.isEmbedded = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final locs = AppLocalizations.of(context)!;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: colorScheme.secondary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.lock_open_outlined,
color: colorScheme.secondary,
),
),
const VSpacer(),
Text(
locs.signupConfirmationLoginTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const VSpacer(),
Text(
locs.signupConfirmationLoginHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const VSpacer(multiplier: 1.5),
OutlinedButton.icon(
onPressed: () => navigate(context, Pages.login),
icon: const Icon(Icons.login),
label: Text(locs.login),
),
],
);
if (isEmbedded) return content;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: content,
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/login/app_bar.dart';
import 'package:pweb/pages/signup/confirmation/card/card.dart';
import 'package:pweb/pages/signup/confirmation/login_prompt.dart';
import 'package:pweb/pages/with_footer.dart';
import 'package:pweb/widgets/vspacer.dart';
class SignUpConfirmationPage extends StatefulWidget {
final String? email;
const SignUpConfirmationPage({super.key, this.email});
@override
State<SignUpConfirmationPage> createState() => _SignUpConfirmationPageState();
}
class _SignUpConfirmationPageState extends State<SignUpConfirmationPage> {
@override
Widget build(BuildContext context) {
final email = widget.email?.trim();
return PageWithFooter(
appBar: const LoginAppBar(),
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 980;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
children: [
Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: isWide ? 980 : 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(
color: Theme.of(context).dividerColor.withValues(alpha: 0.6),
),
),
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SignupConfirmationCard(
email: email,
isEmbedded: true,
),
const VSpacer(multiplier: 2),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withValues(alpha: 0.6),
),
const VSpacer(multiplier: 2),
const SignupConfirmationLoginPrompt(isEmbedded: true),
],
),
),
),
],
),
),
),
],
);
},
),
);
}
}

View File

@@ -39,16 +39,16 @@ class SignUpFormFields extends StatelessWidget {
const VSpacer(),
NotEmptyTextFormField(
controller: controllers.firstName,
labelText: AppLocalizations.of(context)!.lastName,
labelText: AppLocalizations.of(context)!.firstName,
readOnly: false,
error: AppLocalizations.of(context)!.enterLastName,
error: AppLocalizations.of(context)!.enterFirstName,
),
const VSpacer(),
NotEmptyTextFormField(
controller: controllers.lastName,
labelText: AppLocalizations.of(context)!.firstName,
labelText: AppLocalizations.of(context)!.lastName,
readOnly: false,
error: AppLocalizations.of(context)!.enterFirstName,
error: AppLocalizations.of(context)!.enterLastName,
),
const VSpacer(),
EmailField(controller: controllers.email),

View File

@@ -15,7 +15,7 @@ import 'package:pweb/app/router/pages.dart';
import 'package:pweb/pages/signup/form/content.dart';
import 'package:pweb/pages/signup/form/controllers.dart';
import 'package:pweb/pages/signup/form/form.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/pages/signup/confirmation/args.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -91,12 +91,12 @@ class SignUpFormState extends State<SignUpForm> {
void handleSignUp() => signUp(
context,
() {
final locs = AppLocalizations.of(context)!;
notifyUser(
context,
locs.signupSuccess(controllers.email.text.trim()),
context.goNamed(
Pages.signupConfirm.name,
extra: SignupConfirmationArgs(
email: controllers.email.text.trim(),
),
);
context.goNamed(Pages.login.name);
},
(e) => postNotifyUserOfErrorX(
context: context,

View File

@@ -8,7 +8,14 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class StatusPageFailure extends StatelessWidget {
final String errorMessage;
final Object error;
const StatusPageFailure({ super.key, required this.errorMessage, required this.error });
final Widget? action;
const StatusPageFailure({
super.key,
required this.errorMessage,
required this.error,
this.action,
});
@override
Widget build(BuildContext context) => exceptionToErrorPage(

View File

@@ -16,6 +16,7 @@ class StatusPage<T> extends StatefulWidget {
final String successMessage;
final String successDescription;
final Widget? successAction;
final Widget? failureAction;
const StatusPage({
super.key,
@@ -25,6 +26,7 @@ class StatusPage<T> extends StatefulWidget {
required this.successMessage,
required this.successDescription,
this.successAction,
this.failureAction,
});
@override
@@ -58,6 +60,7 @@ class _StatusPageState<T> extends State<StatusPage<T>> {
return StatusPageFailure(
errorMessage: widget.errorMessage,
error: snapshot.error!,
action: widget.failureAction,
);
}
@@ -75,4 +78,3 @@ class _StatusPageState<T> extends State<StatusPage<T>> {
),
);
}

View File

@@ -1,21 +1,81 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/status/page.dart';
import 'package:pweb/services/verification.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/email_verification.dart';
import 'package:pshared/widgets/locale.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/pages/errors/error.dart';
import 'package:pweb/pages/status/success.dart';
import 'package:pweb/pages/with_footer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountVerificationPage extends StatelessWidget {
class AccountVerificationPage extends StatefulWidget {
final String token;
const AccountVerificationPage({super.key, required this.token});
@override
Widget build(BuildContext context) => StatusPage<bool>(
operation: () async => VerificationService.verify(token),
errorMessage: AppLocalizations.of(context)!.accountVerificationFailed,
successMessage: AppLocalizations.of(context)!.accountVerified,
successDescription: AppLocalizations.of(context)!.accountVerifiedDescription,
);
State<AccountVerificationPage> createState() => _AccountVerificationPageState();
}
class _AccountVerificationPageState extends State<AccountVerificationPage> {
@override
void initState() {
super.initState();
context.read<EmailVerificationProvider>().verify(widget.token);
}
@override
Widget build(BuildContext context) {
return const _AccountVerificationContent();
}
}
class _AccountVerificationContent extends StatelessWidget {
const _AccountVerificationContent();
@override
Widget build(BuildContext context) {
final locs = AppLocalizations.of(context)!;
final provider = context.watch<EmailVerificationProvider>();
final action = OutlinedButton.icon(
onPressed: () => navigateAndReplace(context, Pages.login),
icon: const Icon(Icons.login),
label: Text(locs.login),
);
Widget content;
if (provider.isLoading) {
content = const Center(child: CircularProgressIndicator());
} else if (provider.isSuccess) {
content = StatusPageSuccess(
successMessage: locs.accountVerified,
successDescription: locs.accountVerifiedDescription,
action: action,
);
} else {
content = exceptionToErrorPage(
context: context,
title: locs.verificationFailed,
errorMessage: locs.accountVerificationFailed,
exception: provider.error ?? Exception(locs.accountVerificationFailed),
);
}
return PageWithFooter(
appBar: AppBar(
title: Text(locs.verifyAccount),
centerTitle: true,
actions: [
const LocaleChangerDropdown(
availableLocales: AppLocalizations.supportedLocales,
),
],
),
child: content,
);
}
}