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

@@ -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,