Email Confirmation and refactor for snackbar #332

Merged
tech merged 2 commits from SEND036 into main 2026-01-27 16:11:59 +00:00
28 changed files with 969 additions and 177 deletions

View File

@@ -191,6 +191,15 @@ class AccountProvider extends ChangeNotifier {
}
}
Future<void> resendVerificationEmail(String email) async {
try {
await AccountService.resendVerificationEmail(email);
} catch (e) {
_setResource(_resource.copyWith(error: toException(e)));
rethrow;
}
}
Future<void> logout() async {
_authState = AuthState.empty;
_setResource(_resource.copyWith(isLoading: true, error: null));

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/account.dart';
import 'package:pshared/utils/exception.dart';
class EmailVerificationProvider extends ChangeNotifier {
Resource<bool> _resource = Resource(data: null, isLoading: false);
String? _token;
Resource<bool> get resource => _resource;
bool get isLoading => _resource.isLoading;
bool get isSuccess => _resource.data == true;
Exception? get error => _resource.error;
Future<void> verify(String token) async {
final trimmed = token.trim();
if (trimmed.isEmpty) {
_setResource(
Resource(
data: null,
isLoading: false,
error: Exception('Email verification token is empty.'),
),
);
return;
}
if (_token == trimmed && _resource.isLoading) return;
_token = trimmed;
_setResource(Resource(data: null, isLoading: true));
try {
await AccountService.verifyEmail(trimmed);
_setResource(Resource(data: true, isLoading: false));
} catch (e) {
_setResource(
Resource(
data: null,
isLoading: false,
error: toException(e),
),
);
}
}
void _setResource(Resource<bool> resource) {
_resource = resource;
notifyListeners();
}
}

View File

@@ -57,6 +57,16 @@ class AccountService {
await getPUTResponse(_objectType, 'password', ForgotPasswordRequest.build(login: email).toJson());
}
static Future<void> resendVerificationEmail(String email) async {
_logger.fine('Resending verification email');
await getPUTResponse(_objectType, 'email', {'login': email});
}
static Future<void> verifyEmail(String token) async {
_logger.fine('Verifying email');
await getGETResponse(_objectType, 'verify/$token');
}
static Future<void> resetPassword(String accountRef, String token, String newPassword) async {
_logger.fine('Resetting password for account: $accountRef');
await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson());

View File

@@ -9,6 +9,7 @@ enum Pages {
methods,
verify,
signup,
signupConfirm,
settings,
dashboard,
profile,

View File

@@ -8,6 +8,8 @@ import 'package:pweb/pages/2fa/page.dart';
import 'package:pweb/pages/errors/not_found.dart';
import 'package:pweb/pages/login/page.dart';
import 'package:pweb/pages/signup/page.dart';
import 'package:pweb/pages/signup/confirmation/args.dart';
import 'package:pweb/pages/signup/confirmation/page.dart';
import 'package:pweb/pages/verification/page.dart';
@@ -36,6 +38,15 @@ GoRouter createRouter() => GoRouter(
path: routerPage(Pages.signup),
builder: (_, _) => const SignUpPage(),
),
GoRoute(
name: Pages.signupConfirm.name,
path: routerPage(Pages.signupConfirm),
builder: (_, state) => SignUpConfirmationPage(
email: state.extra is SignupConfirmationArgs
? (state.extra as SignupConfirmationArgs).email
: null,
),
),
GoRoute(
name: Pages.verify.name,
path: '${routerPage(Pages.verify)}${routerAddParam(PageParams.token)}',

View File

@@ -41,6 +41,30 @@
"goToSignUp": "Go to Sign Up",
"signupError": "Failed to signup: {error}",
"signupSuccess": "Email confirmation message has been sent to {email}. Please, open it and click link to activate your account.",
"signupConfirmationTitle": "Confirm your email",
"signupConfirmationDescription": "We sent a confirmation link to {email}. Open it and click the link to activate your account.",
"@signupConfirmationDescription": {
"placeholders": {
"email": {}
}
},
"signupConfirmationDescriptionNoEmail": "We sent a confirmation link to your email. Open it and click the link to activate your account.",
"signupConfirmationResend": "Resend email",
"signupConfirmationResendCooldown": "Resend in {time}",
"@signupConfirmationResendCooldown": {
"placeholders": {
"time": {}
}
},
"signupConfirmationResent": "Verification email resent to {email}.",
"@signupConfirmationResent": {
"placeholders": {
"email": {}
}
},
"signupConfirmationResendError": "Failed to resend verification email",
"signupConfirmationLoginTitle": "Log in after confirmation",
"signupConfirmationLoginHint": "Once your email is confirmed, you can sign in below.",
"connectivityError": "Cannot reach the server at {serverAddress}. Check your network and try again.",
"errorAccountNotVerified": "Your account hasn't been verified yet. Please check your email to complete the verification",
"errorLoginUnauthorized": "Login or password is incorrect. Please try again",

View File

@@ -41,6 +41,30 @@
"goToSignUp": "Перейти к регистрации",
"signupError": "Не удалось зарегистрироваться: {error}",
"signupSuccess": "Письмо с подтверждением email отправлено на {email}. Пожалуйста, откройте его и перейдите по ссылке для активации вашего аккаунта.",
"signupConfirmationTitle": "Подтвердите email",
"signupConfirmationDescription": "Мы отправили ссылку для подтверждения на {email}. Откройте письмо и перейдите по ссылке, чтобы активировать аккаунт.",
"@signupConfirmationDescription": {
"placeholders": {
"email": {}
}
},
"signupConfirmationDescriptionNoEmail": "Мы отправили ссылку для подтверждения на ваш email. Откройте письмо и перейдите по ссылке, чтобы активировать аккаунт.",
"signupConfirmationResend": "Отправить письмо снова",
"signupConfirmationResendCooldown": "Повторная отправка через {time}",
"@signupConfirmationResendCooldown": {
"placeholders": {
"time": {}
}
},
"signupConfirmationResent": "Письмо с подтверждением повторно отправлено на {email}.",
"@signupConfirmationResent": {
"placeholders": {
"email": {}
}
},
"signupConfirmationResendError": "Не удалось повторно отправить письмо с подтверждением",
"signupConfirmationLoginTitle": "Войдите после подтверждения",
"signupConfirmationLoginHint": "После подтверждения email вы можете войти ниже.",
"connectivityError": "Не удается связаться с сервером {serverAddress}. Проверьте ваше интернет-соединение и попробуйте снова.",
"errorAccountNotVerified": "Ваш аккаунт еще не подтвержден. Пожалуйста, проверьте вашу электронную почту для завершения верификации",
"errorLoginUnauthorized": "Неверный логин или пароль. Пожалуйста, попробуйте снова",

View File

@@ -17,6 +17,7 @@ import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/email_verification.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/invitations.dart';
@@ -77,6 +78,8 @@ void main() async {
create: (_) => EmployeesProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations),
),
ChangeNotifierProvider(create: (_) => EmailVerificationProvider()),
ChangeNotifierProvider(
create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(),
),

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

@@ -81,17 +81,17 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
return;
}
_stateNotifier.value = EditState.saving;
final sms = ScaffoldMessenger.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final locs = AppLocalizations.of(context)!;
try {
await widget.delegate.valueSetter(newValue);
sms.showSnackBar(SnackBar(
scaffoldMessenger.showSnackBar(SnackBar(
content: Text(locs.settingsSuccessfullyUpdated),
duration: const Duration(milliseconds: 1200),
));
} catch (e) {
notifyUserOfErrorX(
scaffoldMessenger: sms,
showErrorSnackBar(
scaffoldMessenger: scaffoldMessenger,
Outdated
Review

после await контекст лучше не использовать

после await контекст лучше не использовать
errorSituation: widget.delegate.errorSituation,
appLocalizations: locs,
exception: e,
@@ -123,6 +123,7 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
);
},
);
if (!mounted) return;
if (result != null) await _performSave(result);
}

View File

@@ -41,8 +41,8 @@ class ImageTile extends AbstractSettingsTile {
Future<void> _pickImage(BuildContext context) async {
final picker = ImagePicker();
final scaffoldMessenger = ScaffoldMessenger.of(context);
final locs = AppLocalizations.of(context)!;
final sm = ScaffoldMessenger.of(context);
final picked = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: maxWidth,
@@ -56,8 +56,8 @@ class ImageTile extends AbstractSettingsTile {
CachedNetworkImage.evictFromCache(imageUrl!);
}
} catch (e) {
notifyUserOfErrorX(
scaffoldMessenger: sm,
showErrorSnackBar(
Outdated
Review

после await контекст лучше не использовать

после await контекст лучше не использовать
scaffoldMessenger: scaffoldMessenger,
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> {
Review

можно и так, но для простоты можно выносить отдельный класс - контроллер (аналог провайдера, который занимается данными UI). Тут можно не править ничего.

можно и так, но для простоты можно выносить отдельный класс - контроллер (аналог провайдера, который занимается данными UI). Тут можно не править ничего.
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,
);
}
}

View File

@@ -1,28 +1,53 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pshared/utils/snackbar.dart';
import 'package:pweb/utils/error/handler.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/widgets/error/content.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserOfErrorX({
Future<void> notifyUserOfErrorX({
required BuildContext context,
required String errorSituation,
required Object exception,
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) async {
if (!context.mounted) return;
final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
final technicalDetails = exception.toString();
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
if (scaffoldMessenger != null) {
final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation,
localizedError: localizedError,
technicalDetails: technicalDetails,
loc: appLocalizations,
scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds,
);
scaffoldMessenger.showSnackBar(snackBar);
return;
}
await _showErrorDialog(
context,
title: errorSituation,
message: localizedError,
);
}
void showErrorSnackBar({
required ScaffoldMessengerState scaffoldMessenger,
required String errorSituation,
required Object exception,
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) {
// A. Localized user-friendly error message
final String localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
// B. Technical details for advanced reference
final String technicalDetails = exception.toString();
// C. Build the snack bar
final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
final technicalDetails = exception.toString();
final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation,
localizedError: localizedError,
@@ -31,23 +56,22 @@ ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserOfErrorX({
scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds,
);
// D. Show it
return scaffoldMessenger.showSnackBar(snackBar);
scaffoldMessenger.showSnackBar(snackBar);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserOfError({
Future<void> notifyUserOfError({
required BuildContext context,
required String errorSituation,
required Object exception,
int delaySeconds = 3,
}) => notifyUserOfErrorX(
scaffoldMessenger: ScaffoldMessenger.of(context),
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
}) =>
notifyUserOfErrorX(
context: context,
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
Future<T?> executeActionWithNotification<T>({
required BuildContext context,
@@ -56,19 +80,17 @@ Future<T?> executeActionWithNotification<T>({
String? successMessage,
int delaySeconds = 3,
}) async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final localizations = AppLocalizations.of(context)!;
try {
final result = await action();
Outdated
Review

так лучше не делать: маскируешь проблему истечения контекста во время await

так лучше не делать: маскируешь проблему истечения контекста во время await
if (successMessage != null) {
notifyUser(context, successMessage, delaySeconds: delaySeconds);
await notifyUser(context, successMessage, delaySeconds: delaySeconds);
}
return result;
} catch (e) {
// Report the error using your existing notifier.
notifyUserOfErrorX(
scaffoldMessenger: scaffoldMessenger,
await notifyUserOfErrorX(
context: context,
errorSituation: errorMessage,
exception: e,
appLocalizations: localizations,
@@ -78,43 +100,36 @@ Future<T?> executeActionWithNotification<T>({
return null;
}
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUserOfError({
required ScaffoldMessengerState scaffoldMessenger,
Future<void> postNotifyUserOfError({
required BuildContext context,
required String errorSituation,
required Object exception,
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) {
final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
WidgetsBinding.instance.addPostFrameCallback((_) => completer.complete(notifyUserOfErrorX(
scaffoldMessenger: scaffoldMessenger,
}) =>
notifyUserOfErrorX(
context: context,
errorSituation: errorSituation,
exception: exception,
appLocalizations: appLocalizations,
delaySeconds: delaySeconds,
)),
);
);
return completer.future;
}
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUserOfErrorX({
Future<void> postNotifyUserOfErrorX({
required BuildContext context,
required String errorSituation,
required Object exception,
int delaySeconds = 3,
}) => postNotifyUserOfError(
scaffoldMessenger: ScaffoldMessenger.of(context),
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
}) =>
postNotifyUserOfError(
context: context,
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
/// 2) A helper function that returns the main SnackBar widget
SnackBar _buildMainErrorSnackBar({
required String errorSituation,
required String localizedError,
@@ -122,17 +137,42 @@ SnackBar _buildMainErrorSnackBar({
required AppLocalizations loc,
required ScaffoldMessengerState scaffoldMessenger,
int delaySeconds = 3,
}) => SnackBar(
duration: Duration(seconds: delaySeconds),
content: ErrorSnackBarContent(
situation: errorSituation,
localizedError: localizedError,
),
action: SnackBarAction(
label: loc.showDetailsAction,
onPressed: () => scaffoldMessenger.showSnackBar(SnackBar(
content: Text(technicalDetails),
duration: const Duration(seconds: 6),
)),
),
);
}) =>
SnackBar(
duration: Duration(seconds: delaySeconds),
content: ErrorSnackBarContent(
situation: errorSituation,
localizedError: localizedError,
),
action: SnackBarAction(
label: loc.showDetailsAction,
onPressed: () => scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(technicalDetails),
duration: const Duration(seconds: 6),
),
),
),
);
Future<void> _showErrorDialog(
BuildContext context, {
required String title,
required String message,
}) async {
if (!context.mounted) return;
final loc = AppLocalizations.of(context)!;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(loc.ok),
),
],
),
);
}

View File

@@ -13,19 +13,18 @@ Future<void> invokeAndNotify<T>(
void Function(Object)? onError,
void Function(T)? onSuccess,
}) async {
final sm = ScaffoldMessenger.of(context);
final locs = AppLocalizations.of(context)!;
try {
final res = await operation();
if (operationSuccess != null) {
notifyUserX(sm, operationSuccess);
await notifyUser(context, operationSuccess);
}
if (onSuccess != null) {
onSuccess(res);
}
} catch (e) {
notifyUserOfErrorX(
scaffoldMessenger: sm,
context: context,
errorSituation: operationError ?? locs.errorInternalError,
exception: e,
appLocalizations: locs,

View File

@@ -2,28 +2,67 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserX(
ScaffoldMessengerState sm,
String message,
{ int delaySeconds = 3 }
) => sm.showSnackBar(SnackBar(content: Text(message), duration: Duration(seconds: delaySeconds)));
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUser(
Future<void> notifyUserX(
BuildContext context,
String message,
{ int delaySeconds = 3 }
) => notifyUserX(ScaffoldMessenger.of(context), message, delaySeconds: delaySeconds);
String message, {
int delaySeconds = 3,
}) async {
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
Review

допустимо, конечно, но у тебя часть сообщений тогда пропадать будет (из-за истечения контекста)

допустимо, конечно, но у тебя часть сообщений тогда пропадать будет (из-за истечения контекста)
if (scaffoldMessenger != null) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(message),
duration: Duration(seconds: delaySeconds),
),
);
return;
}
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUser(
BuildContext context, String message, {int delaySeconds = 3}) {
await _showMessageDialog(context, message);
}
final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
Future<void> notifyUser(
BuildContext context,
String message, {
int delaySeconds = 3,
}) =>
notifyUserX(context, message, delaySeconds: delaySeconds);
WidgetsBinding.instance.addPostFrameCallback((_) {
final controller = notifyUser(context, message, delaySeconds: delaySeconds);
completer.complete(controller);
Future<void> postNotifyUser(
BuildContext context,
String message, {
int delaySeconds = 3,
}) {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!context.mounted) {
completer.complete();
return;
}
await notifyUser(context, message, delaySeconds: delaySeconds);
completer.complete();
});
return completer.future;
}
Future<void> _showMessageDialog(BuildContext context, String message) async {
if (!context.mounted) return;
final loc = AppLocalizations.of(context)!;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(loc.ok),
),
],
),
);
}

View File

@@ -9,45 +9,51 @@ import 'package:pweb/widgets/error/content.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserOfErrorX({
required ScaffoldMessengerState scaffoldMessenger,
Future<void> notifyUserOfErrorX({
required BuildContext context,
required String errorSituation,
required Object exception,
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) {
// A. Localized user-friendly error message
final String localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
}) async {
if (!context.mounted) return;
final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
final technicalDetails = exception.toString();
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
// B. Technical details for advanced reference
final String technicalDetails = exception.toString();
if (scaffoldMessenger != null) {
final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation,
localizedError: localizedError,
technicalDetails: technicalDetails,
loc: appLocalizations,
scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds,
);
scaffoldMessenger.showSnackBar(snackBar);
return;
}
// C. Build the snack bar
final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation,
localizedError: localizedError,
technicalDetails: technicalDetails,
loc: appLocalizations,
scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds,
await _showErrorDialog(
context,
title: errorSituation,
message: localizedError,
);
// D. Show it
return scaffoldMessenger.showSnackBar(snackBar);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserOfError({
Future<void> notifyUserOfError({
required BuildContext context,
required String errorSituation,
required Object exception,
int delaySeconds = 3,
}) => notifyUserOfErrorX(
scaffoldMessenger: ScaffoldMessenger.of(context),
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
}) =>
notifyUserOfErrorX(
context: context,
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
Future<T?> executeActionWithNotification<T>({
required BuildContext context,
@@ -56,19 +62,17 @@ Future<T?> executeActionWithNotification<T>({
String? successMessage,
int delaySeconds = 3,
}) async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final localizations = AppLocalizations.of(context)!;
try {
final res = await action();
if (successMessage != null) {
notifyUserX(scaffoldMessenger, successMessage, delaySeconds: delaySeconds);
await notifyUser(context, successMessage, delaySeconds: delaySeconds);
}
return res;
} catch (e) {
// Report the error using your existing notifier.
notifyUserOfErrorX(
scaffoldMessenger: scaffoldMessenger,
await notifyUserOfErrorX(
context: context,
errorSituation: errorMessage,
exception: e,
appLocalizations: localizations,
@@ -78,43 +82,48 @@ Future<T?> executeActionWithNotification<T>({
return null;
}
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUserOfError({
required ScaffoldMessengerState scaffoldMessenger,
Future<void> postNotifyUserOfError({
required BuildContext context,
required String errorSituation,
required Object exception,
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) {
final completer = Completer<void>();
final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
WidgetsBinding.instance.addPostFrameCallback((_) => completer.complete(notifyUserOfErrorX(
scaffoldMessenger: scaffoldMessenger,
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!context.mounted) {
completer.complete();
return;
}
await notifyUserOfErrorX(
context: context,
errorSituation: errorSituation,
exception: exception,
appLocalizations: appLocalizations,
delaySeconds: delaySeconds,
)),
);
);
completer.complete();
});
return completer.future;
}
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUserOfErrorX({
Future<void> postNotifyUserOfErrorX({
required BuildContext context,
required String errorSituation,
required Object exception,
int delaySeconds = 3,
}) => postNotifyUserOfError(
scaffoldMessenger: ScaffoldMessenger.of(context),
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
}) =>
postNotifyUserOfError(
context: context,
errorSituation: errorSituation,
exception: exception,
appLocalizations: AppLocalizations.of(context)!,
delaySeconds: delaySeconds,
);
/// 2) A helper function that returns the main SnackBar widget
SnackBar _buildMainErrorSnackBar({
required String errorSituation,
required String localizedError,
@@ -122,17 +131,42 @@ SnackBar _buildMainErrorSnackBar({
required AppLocalizations loc,
required ScaffoldMessengerState scaffoldMessenger,
int delaySeconds = 3,
}) => SnackBar(
duration: Duration(seconds: delaySeconds),
content: ErrorSnackBarContent(
situation: errorSituation,
localizedError: localizedError,
),
action: SnackBarAction(
label: loc.showDetailsAction,
onPressed: () => scaffoldMessenger.showSnackBar(SnackBar(
content: Text(technicalDetails),
duration: const Duration(seconds: 6),
)),
),
);
}) =>
SnackBar(
duration: Duration(seconds: delaySeconds),
content: ErrorSnackBarContent(
situation: errorSituation,
localizedError: localizedError,
),
action: SnackBarAction(
label: loc.showDetailsAction,
onPressed: () => scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(technicalDetails),
duration: const Duration(seconds: 6),
),
),
),
);
Future<void> _showErrorDialog(
BuildContext context, {
required String title,
required String message,
}) async {
if (!context.mounted) return;
final loc = AppLocalizations.of(context)!;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(loc.ok),
),
],
),
);
}