diff --git a/frontend/pshared/lib/provider/email_verification.dart b/frontend/pshared/lib/provider/email_verification.dart index 3d1f21f0..067aaf89 100644 --- a/frontend/pshared/lib/provider/email_verification.dart +++ b/frontend/pshared/lib/provider/email_verification.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:pshared/api/responses/error/server.dart'; import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/account.dart'; import 'package:pshared/utils/exception.dart'; - class EmailVerificationProvider extends ChangeNotifier { Resource _resource = Resource(data: null, isLoading: false); String? _token; @@ -13,6 +13,11 @@ class EmailVerificationProvider extends ChangeNotifier { bool get isLoading => _resource.isLoading; bool get isSuccess => _resource.data == true; Exception? get error => _resource.error; + int? get errorCode => _resource.error is ErrorResponse + ? (_resource.error as ErrorResponse).code + : null; + bool get canResendVerification => + errorCode == 400 || errorCode == 410 || errorCode == 500; Future verify(String token) async { final trimmed = token.trim(); @@ -33,12 +38,12 @@ class EmailVerificationProvider extends ChangeNotifier { await AccountService.verifyEmail(trimmed); _setResource(Resource(data: true, isLoading: false)); } catch (e) { + if (e is ErrorResponse && e.code == 404) { + _setResource(Resource(data: true, isLoading: false)); + return; + } _setResource( - Resource( - data: null, - isLoading: false, - error: toException(e), - ), + Resource(data: null, isLoading: false, error: toException(e)), ); } } diff --git a/frontend/pweb/lib/pages/errors/error.dart b/frontend/pweb/lib/pages/errors/error.dart index 3f898d15..36135742 100644 --- a/frontend/pweb/lib/pages/errors/error.dart +++ b/frontend/pweb/lib/pages/errors/error.dart @@ -11,12 +11,14 @@ class ErrorPage extends StatelessWidget { final String title; final String errorMessage; final String errorHint; + final Widget? action; - const ErrorPage({ - super.key, + const ErrorPage({ + super.key, required this.title, - required this.errorMessage, + required this.errorMessage, required this.errorHint, + this.action, }); @override @@ -26,19 +28,34 @@ class ErrorPage extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error), + Icon( + Icons.error_outline, + size: 72, + color: Theme.of(context).colorScheme.error, + ), const VSpacer(), Text( title, - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).colorScheme.error), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), textAlign: TextAlign.center, ), const VSpacer(multiplier: 0.5), ListTile( - title: Text(errorMessage, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge), - subtitle: Text(errorHint, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall), + title: Text( + errorMessage, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, + ), + subtitle: Text( + errorHint, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), ), const VSpacer(multiplier: 1.5), + if (action != null) ...[action!, const VSpacer(multiplier: 0.5)], TextButton( onPressed: () => navigate(context, Pages.root), child: Text(AppLocalizations.of(context)!.goToMainPage), @@ -54,8 +71,10 @@ Widget exceptionToErrorPage({ required String title, required String errorMessage, required Object exception, + Widget? action, }) => ErrorPage( - title: title, - errorMessage: errorMessage, + title: title, + errorMessage: errorMessage, errorHint: ErrorHandler.handleError(context, exception), + action: action, ); diff --git a/frontend/pweb/lib/pages/verification/content.dart b/frontend/pweb/lib/pages/verification/content.dart new file mode 100644 index 00000000..b5c82f88 --- /dev/null +++ b/frontend/pweb/lib/pages/verification/content.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.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/pages/verification/controller.dart'; +import 'package:pweb/pages/verification/resend_dialog.dart'; +import 'package:pweb/utils/snackbar.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AccountVerificationContent extends StatefulWidget { + const AccountVerificationContent(); + + @override + State createState() => + AccountVerificationContentState(); +} + +class AccountVerificationContentState + extends State { + Future _resendVerificationEmail() async { + final controller = context.read(); + if (controller.isResending) return; + final locs = AppLocalizations.of(context)!; + final email = await requestVerificationEmail(context, locs); + if (!mounted || email == null) return; + if (email.isEmpty) { + notifyUser(context, locs.errorEmailMissing); + return; + } + + try { + await controller.resendVerificationEmail(email); + if (!mounted) return; + await notifyUser(context, locs.signupConfirmationResent(email)); + } catch (e) { + if (!mounted) return; + await postNotifyUserOfErrorX( + context: context, + errorSituation: locs.signupConfirmationResendError, + exception: e, + ); + } + } + + @override + Widget build(BuildContext context) { + final locs = AppLocalizations.of(context)!; + final controller = context.watch(); + final action = OutlinedButton.icon( + onPressed: () => navigateAndReplace(context, Pages.login), + icon: const Icon(Icons.login), + label: Text(locs.login), + ); + + Widget content; + if (controller.isLoading) { + content = const Center(child: CircularProgressIndicator()); + } else if (controller.isSuccess) { + content = StatusPageSuccess( + successMessage: locs.accountVerified, + successDescription: locs.accountVerifiedDescription, + action: action, + ); + } else { + content = exceptionToErrorPage( + context: context, + title: locs.verificationFailed, + errorMessage: locs.accountVerificationFailed, + exception: + controller.error ?? Exception(locs.accountVerificationFailed), + action: controller.canResend + ? OutlinedButton.icon( + onPressed: controller.isResending + ? null + : _resendVerificationEmail, + icon: controller.isResending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.mark_email_unread_outlined), + label: Text(locs.signupConfirmationResend), + ) + : null, + ); + } + + return PageWithFooter( + appBar: AppBar( + title: Text(locs.verifyAccount), + centerTitle: true, + actions: [ + const LocaleChangerDropdown( + availableLocales: AppLocalizations.supportedLocales, + ), + ], + ), + child: content, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/verification/controller.dart b/frontend/pweb/lib/pages/verification/controller.dart new file mode 100644 index 00000000..c6dc3b3d --- /dev/null +++ b/frontend/pweb/lib/pages/verification/controller.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/email_verification.dart'; + +import 'package:pweb/models/flow_status.dart'; + + +class AccountVerificationController extends ChangeNotifier { + AccountVerificationController({ + required AccountProvider accountProvider, + required EmailVerificationProvider verificationProvider, + }) : _accountProvider = accountProvider, + _verificationProvider = verificationProvider { + _verificationProvider.addListener(_onVerificationChanged); + } + + final AccountProvider _accountProvider; + final EmailVerificationProvider _verificationProvider; + + FlowStatus _resendStatus = FlowStatus.idle; + String? _verificationToken; + + bool get isLoading => _verificationProvider.isLoading; + bool get isSuccess => _verificationProvider.isSuccess; + Exception? get error => _verificationProvider.error; + bool get canResend => _verificationProvider.canResendVerification; + bool get isResending => _resendStatus == FlowStatus.resending; + + void startVerification(String token) { + final trimmed = token.trim(); + if (trimmed.isEmpty || trimmed == _verificationToken) return; + _verificationToken = trimmed; + _verificationProvider.verify(trimmed); + } + + Future resendVerificationEmail(String email) async { + final trimmed = email.trim(); + if (trimmed.isEmpty || isResending) return; + _setResendStatus(FlowStatus.resending); + try { + await _accountProvider.resendVerificationEmail(trimmed); + _setResendStatus(FlowStatus.idle); + } catch (_) { + _setResendStatus(FlowStatus.error); + rethrow; + } + } + + void _onVerificationChanged() { + notifyListeners(); + } + + void _setResendStatus(FlowStatus status) { + _resendStatus = status; + notifyListeners(); + } + + @override + void dispose() { + _verificationProvider.removeListener(_onVerificationChanged); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/pages/verification/page.dart b/frontend/pweb/lib/pages/verification/page.dart index c0e901fe..5558d196 100644 --- a/frontend/pweb/lib/pages/verification/page.dart +++ b/frontend/pweb/lib/pages/verification/page.dart @@ -2,81 +2,26 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:pshared/provider/account.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'; +import 'package:pweb/pages/verification/content.dart'; +import 'package:pweb/pages/verification/controller.dart'; -class AccountVerificationPage extends StatefulWidget { +class AccountVerificationPage extends StatelessWidget { final String token; const AccountVerificationPage({super.key, required this.token}); - @override - State createState() => _AccountVerificationPageState(); -} - -class _AccountVerificationPageState extends State { - @override - void initState() { - super.initState(); - context.read().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(); - 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, + return ChangeNotifierProvider( + create: (context) => AccountVerificationController( + accountProvider: context.read(), + verificationProvider: context.read(), + )..startVerification(token), + child: AccountVerificationContent(), ); } -} +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/verification/resend_dialog.dart b/frontend/pweb/lib/pages/verification/resend_dialog.dart new file mode 100644 index 00000000..810b09a2 --- /dev/null +++ b/frontend/pweb/lib/pages/verification/resend_dialog.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +Future requestVerificationEmail( + BuildContext context, + AppLocalizations locs, +) async { + final controller = TextEditingController(); + final email = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(locs.signupConfirmationResend), + content: TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: locs.username, + hintText: locs.usernameHint, + ), + onSubmitted: (_) => + Navigator.of(dialogContext).pop(controller.text.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(locs.cancel), + ), + FilledButton( + onPressed: () => + Navigator.of(dialogContext).pop(controller.text.trim()), + child: Text(locs.signupConfirmationResend), + ), + ], + ), + ); + controller.dispose(); + return email?.trim(); +}