1 Commits

Author SHA1 Message Date
Arseni
5d330c8ccc Email confirmation page after SignUp 2026-01-13 15:31:13 +03:00
12 changed files with 343 additions and 9 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

@@ -57,6 +57,11 @@ 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> 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,
@@ -57,4 +58,4 @@ void navigateNamedAndReplace(BuildContext context, Pages page, {String? objectRe
void navigateNext(BuildContext context, Pages page, {Object? extra}) {
WidgetsBinding.instance.addPostFrameCallback((_) => navigate(context, page, extra: extra));
}
}

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)}',
@@ -46,4 +57,4 @@ GoRouter createRouter() => GoRouter(
payoutShellRoute(),
],
errorBuilder: (_, __) => const NotFoundPage(),
);
);

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

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

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

View File

@@ -0,0 +1,163 @@
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';
class SignupConfirmationCard extends StatefulWidget {
final String? email;
const SignupConfirmationCard({super.key, this.email});
@override
State<SignupConfirmationCard> createState() => _SignupConfirmationCardState();
}
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;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locs.signupConfirmationTitle,
style: theme.textTheme.headlineSmall,
),
const VSpacer(),
Text(description, style: theme.textTheme.bodyMedium),
if (email != null && email.isNotEmpty) ...[
const VSpacer(),
SelectableText(
email,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
const VSpacer(multiplier: 1.5),
Row(
children: [
ElevatedButton.icon(
onPressed: canResend ? _resendVerificationEmail : 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,31 @@
import 'package:flutter/material.dart';
import 'package:pweb/widgets/vspacer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SignupConfirmationLoginPrompt extends StatelessWidget {
const SignupConfirmationLoginPrompt({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locs = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locs.signupConfirmationLoginTitle,
style: theme.textTheme.titleLarge,
),
const VSpacer(),
Text(
locs.signupConfirmationLoginHint,
style: theme.textTheme.bodyMedium,
),
],
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/login/app_bar.dart';
import 'package:pweb/pages/login/form.dart';
import 'package:pweb/pages/signup/confirmation/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: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: SignupConfirmationCard(email: email),
),
),
const VSpacer(multiplier: 2),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: const SignupConfirmationLoginPrompt(),
),
),
const VSpacer(multiplier: 1.5),
LoginForm(initialEmail: 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,