From e632da234425dced23eec89ea6f6a988db9211ef Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 00:27:16 +0100 Subject: [PATCH] feat(auth): add registration, check-email and email-link screens Implements KTO-01 criteria 1-4, 10 and 11 (client part). Refs #1 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/core/config/auth_config.dart | 9 + app/lib/core/platform/platform_info.dart | 14 + app/lib/core/platform/platform_info.g.dart | 63 +++ app/lib/core/platform/url_opener.dart | 21 + app/lib/core/platform/url_opener.g.dart | 55 ++ app/lib/core/routing/app_router.dart | 2 +- app/lib/core/widgets/app_text_field.dart | 7 + .../auth/domain/email_link_request.dart | 19 + .../auth/presentation/auth_failure_texts.dart | 38 ++ .../presentation/auth_route_placeholder.dart | 27 +- .../auth/presentation/auth_routes.dart | 36 ++ .../check_email/check_email_screen.dart | 145 ++++++ .../check_email/resend_controller.dart | 66 +++ .../resend_controller.freezed.dart | 318 ++++++++++++ .../check_email/resend_controller.g.dart | 83 +++ .../email_link/email_link_controller.dart | 63 +++ .../email_link_controller.freezed.dart | 311 ++++++++++++ .../email_link/email_link_controller.g.dart | 130 +++++ .../email_link/email_link_screen.dart | 135 +++++ .../providers/auth_providers.dart | 8 +- .../providers/auth_providers.g.dart | 16 +- .../register/register_controller.dart | 164 ++++++ .../register/register_controller.g.dart | 68 +++ .../register/register_screen.dart | 309 +++++++++++ .../presentation/register/register_state.dart | 115 +++++ .../register/register_state.freezed.dart | 368 ++++++++++++++ .../widgets/auth_screen_title.dart | 16 + app/lib/l10n/app_de.arb | 61 ++- app/lib/l10n/app_en.arb | 92 +++- app/lib/l10n/app_localizations.dart | 354 +++++++++++++ app/lib/l10n/app_localizations_de.dart | 206 ++++++++ app/lib/l10n/app_localizations_en.dart | 203 ++++++++ app/pubspec.lock | 2 +- app/pubspec.yaml | 1 + app/test/core/config/auth_config_test.dart | 10 + .../core/widgets/app_text_field_test.dart | 14 + .../auth/presentation/auth_router_test.dart | 35 +- .../auth/presentation/auth_ui_support.dart | 126 +++++ .../check_email/check_email_screen_test.dart | 223 ++++++++ .../check_email/resend_controller_test.dart | 122 +++++ .../email_link_controller_test.dart | 121 +++++ .../email_link/email_link_screen_test.dart | 243 +++++++++ .../register/register_controller_test.dart | 408 +++++++++++++++ .../register/register_screen_test.dart | 480 ++++++++++++++++++ docs/design-system.md | 2 +- 45 files changed, 5255 insertions(+), 54 deletions(-) create mode 100644 app/lib/core/config/auth_config.dart create mode 100644 app/lib/core/platform/platform_info.dart create mode 100644 app/lib/core/platform/platform_info.g.dart create mode 100644 app/lib/core/platform/url_opener.dart create mode 100644 app/lib/core/platform/url_opener.g.dart create mode 100644 app/lib/features/auth/domain/email_link_request.dart create mode 100644 app/lib/features/auth/presentation/auth_failure_texts.dart create mode 100644 app/lib/features/auth/presentation/auth_routes.dart create mode 100644 app/lib/features/auth/presentation/check_email/check_email_screen.dart create mode 100644 app/lib/features/auth/presentation/check_email/resend_controller.dart create mode 100644 app/lib/features/auth/presentation/check_email/resend_controller.freezed.dart create mode 100644 app/lib/features/auth/presentation/check_email/resend_controller.g.dart create mode 100644 app/lib/features/auth/presentation/email_link/email_link_controller.dart create mode 100644 app/lib/features/auth/presentation/email_link/email_link_controller.freezed.dart create mode 100644 app/lib/features/auth/presentation/email_link/email_link_controller.g.dart create mode 100644 app/lib/features/auth/presentation/email_link/email_link_screen.dart create mode 100644 app/lib/features/auth/presentation/register/register_controller.dart create mode 100644 app/lib/features/auth/presentation/register/register_controller.g.dart create mode 100644 app/lib/features/auth/presentation/register/register_screen.dart create mode 100644 app/lib/features/auth/presentation/register/register_state.dart create mode 100644 app/lib/features/auth/presentation/register/register_state.freezed.dart create mode 100644 app/lib/features/auth/presentation/widgets/auth_screen_title.dart create mode 100644 app/test/core/config/auth_config_test.dart create mode 100644 app/test/features/auth/presentation/auth_ui_support.dart create mode 100644 app/test/features/auth/presentation/check_email/check_email_screen_test.dart create mode 100644 app/test/features/auth/presentation/check_email/resend_controller_test.dart create mode 100644 app/test/features/auth/presentation/email_link/email_link_controller_test.dart create mode 100644 app/test/features/auth/presentation/email_link/email_link_screen_test.dart create mode 100644 app/test/features/auth/presentation/register/register_controller_test.dart create mode 100644 app/test/features/auth/presentation/register/register_screen_test.dart diff --git a/app/lib/core/config/auth_config.dart b/app/lib/core/config/auth_config.dart new file mode 100644 index 0000000..005b1bc --- /dev/null +++ b/app/lib/core/config/auth_config.dart @@ -0,0 +1,9 @@ +/// Auth settings the client has to know. They mirror the server configuration. +abstract final class AuthConfig { + /// Validity of confirmation and reset links (AUTH-CFG-07, + /// `GOTRUE_MAILER_OTP_EXP=3600`, see `docs/auth-konfiguration.md`). + /// + /// The text `authCheckEmailValidityOneHour` is only correct while this is + /// one hour; a test pins the value. + static const linkValidity = Duration(hours: 1); +} diff --git a/app/lib/core/platform/platform_info.dart b/app/lib/core/platform/platform_info.dart new file mode 100644 index 0000000..f2dd401 --- /dev/null +++ b/app/lib/core/platform/platform_info.dart @@ -0,0 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'platform_info.g.dart'; + +/// `true` for the web app in a mobile browser (iOS or Android user agent). +/// +/// Kept in one provider so that platform checks are not scattered over the +/// UI; tests override it. +@Riverpod(keepAlive: true) +bool isMobileWeb(Ref ref) => + kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); diff --git a/app/lib/core/platform/platform_info.g.dart b/app/lib/core/platform/platform_info.g.dart new file mode 100644 index 0000000..93751fa --- /dev/null +++ b/app/lib/core/platform/platform_info.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'platform_info.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// `true` for the web app in a mobile browser (iOS or Android user agent). +/// +/// Kept in one provider so that platform checks are not scattered over the +/// UI; tests override it. + +@ProviderFor(isMobileWeb) +final isMobileWebProvider = IsMobileWebProvider._(); + +/// `true` for the web app in a mobile browser (iOS or Android user agent). +/// +/// Kept in one provider so that platform checks are not scattered over the +/// UI; tests override it. + +final class IsMobileWebProvider extends $FunctionalProvider + with $Provider { + /// `true` for the web app in a mobile browser (iOS or Android user agent). + /// + /// Kept in one provider so that platform checks are not scattered over the + /// UI; tests override it. + IsMobileWebProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'isMobileWebProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$isMobileWebHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return isMobileWeb(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$isMobileWebHash() => r'd287a0a13cb71abe06b3d01885723f1d3ac520b1'; diff --git a/app/lib/core/platform/url_opener.dart b/app/lib/core/platform/url_opener.dart new file mode 100644 index 0000000..ac94a4b --- /dev/null +++ b/app/lib/core/platform/url_opener.dart @@ -0,0 +1,21 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'url_opener.g.dart'; + +/// Opens [uri] outside the app (browser). Returns whether it worked. +typedef UrlOpener = Future Function(Uri uri); + +final _log = AppLogger('UrlOpener'); + +/// The only place that touches `url_launcher`. Tests override this provider. +@Riverpod(keepAlive: true) +UrlOpener urlOpener(Ref ref) => (uri) async { + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } on Object catch (error) { + _log.warning('launchUrl failed: ${error.runtimeType}'); + return false; + } +}; diff --git a/app/lib/core/platform/url_opener.g.dart b/app/lib/core/platform/url_opener.g.dart new file mode 100644 index 0000000..061a48e --- /dev/null +++ b/app/lib/core/platform/url_opener.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'url_opener.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// The only place that touches `url_launcher`. Tests override this provider. + +@ProviderFor(urlOpener) +final urlOpenerProvider = UrlOpenerProvider._(); + +/// The only place that touches `url_launcher`. Tests override this provider. + +final class UrlOpenerProvider + extends $FunctionalProvider + with $Provider { + /// The only place that touches `url_launcher`. Tests override this provider. + UrlOpenerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'urlOpenerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$urlOpenerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + UrlOpener create(Ref ref) { + return urlOpener(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(UrlOpener value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$urlOpenerHash() => r'f5f1e650e073776595fac2981548a91f815c66ca'; diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index a654112..44348ba 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -3,7 +3,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shuffle/core/routing/app_routes.dart'; import 'package:shuffle/core/routing/not_found_screen.dart'; import 'package:shuffle/core/routing/redirect_source.dart'; -import 'package:shuffle/features/auth/presentation/auth_route_placeholder.dart'; +import 'package:shuffle/features/auth/presentation/auth_routes.dart'; import 'package:shuffle/features/home/presentation/home_screen.dart'; part 'app_router.g.dart'; diff --git a/app/lib/core/widgets/app_text_field.dart b/app/lib/core/widgets/app_text_field.dart index a2feacf..701e0ca 100644 --- a/app/lib/core/widgets/app_text_field.dart +++ b/app/lib/core/widgets/app_text_field.dart @@ -27,6 +27,7 @@ class AppTextField extends StatelessWidget { this.suffixIcon, this.maxLength, this.showCounter = false, + this.counterText, this.inputFormatters, this.enabled = true, this.autofocus = false, @@ -57,6 +58,11 @@ class AppTextField extends StatelessWidget { /// Shows a `n/max` counter (needs [maxLength]). final bool showCounter; + + /// Optional text for the counter (e.g. "4/30"). Use it when the counted + /// unit differs from the default (graphemes of the raw text). Only shown + /// when [showCounter] is set. + final String? counterText; final List? inputFormatters; final bool enabled; final bool autofocus; @@ -115,6 +121,7 @@ class AppTextField extends StatelessWidget { onEditingComplete: onEditingComplete, decoration: InputDecoration( suffixIcon: suffixIcon, + counterText: showCounter ? counterText : null, error: hasError ? AppFieldError(errorText!) : null, helper: !hasError && helper != null && helper.isNotEmpty ? Text( diff --git a/app/lib/features/auth/domain/email_link_request.dart b/app/lib/features/auth/domain/email_link_request.dart new file mode 100644 index 0000000..e2e98a4 --- /dev/null +++ b/app/lib/features/auth/domain/email_link_request.dart @@ -0,0 +1,19 @@ +import 'package:shuffle/features/auth/domain/email_link_type.dart'; + +/// Parameters of a mail link `/auth/confirm?token_hash=…&type=…`. +class EmailLinkRequest { + const EmailLinkRequest({required this.tokenHash, required this.type}); + + final String tokenHash; + final EmailLinkType type; + + /// `null` if the token is missing or the type is unknown. + static EmailLinkRequest? tryParse(String? tokenHash, String? type) { + final parsedType = EmailLinkType.tryParse(type); + final token = tokenHash?.trim(); + if (token == null || token.isEmpty || parsedType == null) { + return null; + } + return EmailLinkRequest(tokenHash: token, type: parsedType); + } +} diff --git a/app/lib/features/auth/presentation/auth_failure_texts.dart b/app/lib/features/auth/presentation/auth_failure_texts.dart new file mode 100644 index 0000000..5471f57 --- /dev/null +++ b/app/lib/features/auth/presentation/auth_failure_texts.dart @@ -0,0 +1,38 @@ +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; + +/// User-facing text of an [AuthFailure] (Spec #1 § 4.4, texts from +/// `docs/design/auth-screens.md`). +/// +/// `emailSendLimited` has no text of its own: screens treat it as success +/// (F12) or convert it to `rateLimited` beforehand. +String authFailureMessage(AppLocalizations l10n, AuthFailure failure) => + switch (failure) { + InvalidCredentials() => l10n.authErrorInvalidCredentials, + EmailNotConfirmed() => l10n.authErrorEmailNotConfirmed, + EmailSendLimited() || RateLimited() => l10n.authErrorRateLimited, + WeakPassword(:final reasons) => weakPasswordMessage(l10n, reasons), + SamePassword() => l10n.authErrorSamePassword, + LinkInvalidOrExpired() => l10n.authErrorLinkInvalidOrExpired, + InvalidEmail() => l10n.authErrorEmailInvalid, + RegistrationRejected() => l10n.authErrorRegistrationRejected, + NetworkFailure() => l10n.authErrorNetwork, + Unexpected() => l10n.authErrorUnexpected, + }; + +/// One sentence per distinct reason; an empty list gets the generic text. +String weakPasswordMessage( + AppLocalizations l10n, + List reasons, +) { + final texts = { + for (final reason in reasons) + switch (reason) { + WeakPasswordReason.length => l10n.authErrorWeakPasswordLength, + WeakPasswordReason.pwned => l10n.authErrorWeakPasswordPwned, + WeakPasswordReason.characters || + WeakPasswordReason.unknown => l10n.authErrorWeakPasswordCharacters, + }, + }; + return texts.isEmpty ? l10n.authErrorWeakPasswordCharacters : texts.join(' '); +} diff --git a/app/lib/features/auth/presentation/auth_route_placeholder.dart b/app/lib/features/auth/presentation/auth_route_placeholder.dart index 75b4950..0da02a7 100644 --- a/app/lib/features/auth/presentation/auth_route_placeholder.dart +++ b/app/lib/features/auth/presentation/auth_route_placeholder.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:shuffle/core/routing/app_routes.dart'; -/// Platzhalter für Auth-Seiten, bis A2 bis A4 die Screens liefern. Ohne -/// sichtbare Texte (keine hartkodierten UI-Strings). +/// Placeholder for auth pages that 01b and 01c still deliver. Without visible +/// texts (no hard-coded UI strings). class AuthRoutePlaceholder extends StatelessWidget { const AuthRoutePlaceholder({required this.routeKey, super.key}); @@ -14,27 +13,7 @@ class AuthRoutePlaceholder extends StatelessWidget { Scaffold(key: Key('auth_placeholder_$routeKey'), body: const SizedBox()); } -/// Routen der Auth-Seiten nach Spec #1 § 5.3. A2 bis A4 ersetzen die Builder. -final List authRoutes = [ - _placeholder(AppRoutes.login, 'login'), - GoRoute( - path: AppRoutes.register, - builder: (context, state) => - const AuthRoutePlaceholder(routeKey: 'register'), - routes: [ - GoRoute( - path: 'check-email', - builder: (context, state) => - const AuthRoutePlaceholder(routeKey: 'check_email'), - ), - ], - ), - _placeholder(AppRoutes.forgotPassword, 'forgot_password'), - _placeholder(AppRoutes.authConfirm, 'auth_confirm'), - _placeholder(AppRoutes.resetPassword, 'reset_password'), -]; - -GoRoute _placeholder(String path, String key) => GoRoute( +GoRoute authPlaceholderRoute(String path, String key) => GoRoute( path: path, builder: (context, state) => AuthRoutePlaceholder(routeKey: key), ); diff --git a/app/lib/features/auth/presentation/auth_routes.dart b/app/lib/features/auth/presentation/auth_routes.dart new file mode 100644 index 0000000..8a0752e --- /dev/null +++ b/app/lib/features/auth/presentation/auth_routes.dart @@ -0,0 +1,36 @@ +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/features/auth/presentation/auth_route_placeholder.dart'; +import 'package:shuffle/features/auth/presentation/check_email/check_email_screen.dart'; +import 'package:shuffle/features/auth/presentation/email_link/email_link_screen.dart'; +import 'package:shuffle/features/auth/presentation/register/register_screen.dart'; + +/// Routes of the auth pages (Spec #1 § 5.3). Login, forgot and reset password +/// are placeholders until 01b and 01c. +final List authRoutes = [ + authPlaceholderRoute(AppRoutes.login, 'login'), + GoRoute( + path: AppRoutes.register, + builder: (context, state) => const RegisterScreen(), + routes: [ + GoRoute( + path: 'check-email', + builder: (context, state) { + // The address travels as `extra`, never in the URL. After a web + // reload it is gone; the screen then asks for it. + final extra = state.extra; + return CheckEmailScreen(email: extra is String ? extra : null); + }, + ), + ], + ), + authPlaceholderRoute(AppRoutes.forgotPassword, 'forgot_password'), + GoRoute( + path: AppRoutes.authConfirm, + builder: (context, state) => EmailLinkScreen( + tokenHash: state.uri.queryParameters['token_hash'], + type: state.uri.queryParameters['type'], + ), + ), + authPlaceholderRoute(AppRoutes.resetPassword, 'reset_password'), +]; diff --git a/app/lib/features/auth/presentation/check_email/check_email_screen.dart b/app/lib/features/auth/presentation/check_email/check_email_screen.dart new file mode 100644 index 0000000..5b532cc --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/check_email_screen.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/config/auth_config.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// "Check your inbox" after the registration (`/register/check-email`, +/// KTO-01 Kriterium 1, 4 und 6). +/// +/// The text is neutral: it does not reveal whether the address already has an +/// account (Kriterium 4). [email] comes from the router `extra`; after a web +/// reload it is missing and the user types it in for "resend". +class CheckEmailScreen extends ConsumerStatefulWidget { + const CheckEmailScreen({this.email, super.key}); + + final String? email; + + @override + ConsumerState createState() => _CheckEmailScreenState(); +} + +class _CheckEmailScreenState extends ConsumerState { + final _emailController = TextEditingController(); + + bool get _hasKnownEmail => (widget.email ?? '').trim().isNotEmpty; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + void _resend() { + final email = _hasKnownEmail ? widget.email! : _emailController.text; + ref + .read(resendControllerProvider.notifier) + .resend(email, origin: ResendOrigin.checkEmail); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(resendControllerProvider); + final l10n = context.l10n; + final theme = Theme.of(context); + final failure = state.failure; + + return AppFormScaffold( + key: const Key('check_email_screen'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(l10n.authCheckEmailTitle), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.authCheckEmailBody, + key: const Key('check_email_body'), + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.authCheckEmailValidity(_validityText(l10n)), + key: const Key('check_email_validity'), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.authCheckEmailDelayHint, + key: const Key('check_email_delay_hint'), + style: theme.textTheme.bodyMedium, + ), + if (state.sent) ...[ + const SizedBox(height: AppSpacing.lg), + AppBanner( + key: const Key('check_email_resent_banner'), + type: AppBannerType.info, + typeLabel: l10n.authBannerTypeInfo, + message: l10n.authCheckEmailResent, + ), + ], + if (failure != null) ...[ + const SizedBox(height: AppSpacing.lg), + AppBanner( + key: const Key('check_email_error_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + ), + ], + if (!_hasKnownEmail) ...[ + const SizedBox(height: AppSpacing.xl), + AppTextField( + key: const Key('check_email_email'), + label: l10n.authFieldEmailLabel, + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.email], + autocorrect: false, + enableSuggestions: false, + enabled: !state.isSending, + errorText: state.emailError == null + ? null + : l10n.authErrorEmailInvalid, + onChanged: (_) => + ref.read(resendControllerProvider.notifier).clearEmailError(), + onSubmitted: (_) => _resend(), + ), + ], + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('check_email_resend'), + label: l10n.authCheckEmailResend, + loadingLabel: l10n.authSubmitting, + isLoading: state.isSending, + onPressed: _resend, + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + key: const Key('check_email_back_to_login'), + onPressed: () => context.go(AppRoutes.login), + child: Text(l10n.authBackToLogin), + ), + ], + ), + ); + } + + String _validityText(AppLocalizations l10n) { + assert( + AuthConfig.linkValidity == const Duration(hours: 1), + 'Update authCheckEmailValidityOneHour (AUTH-CFG-07 changed).', + ); + return l10n.authCheckEmailValidityOneHour; + } +} diff --git a/app/lib/features/auth/presentation/check_email/resend_controller.dart b/app/lib/features/auth/presentation/check_email/resend_controller.dart new file mode 100644 index 0000000..3e43dae --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/resend_controller.dart @@ -0,0 +1,66 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +part 'resend_controller.freezed.dart'; +part 'resend_controller.g.dart'; + +@freezed +abstract class ResendState with _$ResendState { + const factory ResendState({ + @Default(false) bool isSending, + + /// The last request counts as sent (also for `emailSendLimited` from the + /// check-email screen, F12). + @Default(false) bool sent, + AuthFailure? failure, + + /// Only for an address typed in by the user. + EmailError? emailError, + }) = _ResendState; +} + +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). +@riverpod +class ResendController extends _$ResendController { + @override + ResendState build() => const ResendState(); + + void clearEmailError() { + if (state.emailError != null) { + state = state.copyWith(emailError: null); + } + } + + Future resend(String email, {required ResendOrigin origin}) async { + if (state.isSending) { + return; + } + final normalized = EmailFormat.normalize(email); + final emailError = EmailFormat.validate(normalized); + if (emailError != null) { + state = ResendState(emailError: emailError); + return; + } + + state = const ResendState(isSending: true); + final result = await ref + .read(authRepositoryProvider) + .resendConfirmation(normalized); + if (!ref.mounted) { + return; + } + final raw = result.errorOrNull; + final failure = raw == null ? null : resendFailureFor(raw, origin); + state = failure == null + ? const ResendState(sent: true) + : ResendState(failure: failure); + } +} diff --git a/app/lib/features/auth/presentation/check_email/resend_controller.freezed.dart b/app/lib/features/auth/presentation/check_email/resend_controller.freezed.dart new file mode 100644 index 0000000..0fbffd2 --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/resend_controller.freezed.dart @@ -0,0 +1,318 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'resend_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ResendState { + + bool get isSending;/// The last request counts as sent (also for `emailSendLimited` from the +/// check-email screen, F12). + bool get sent; AuthFailure? get failure;/// Only for an address typed in by the user. + EmailError? get emailError; +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ResendStateCopyWith get copyWith => _$ResendStateCopyWithImpl(this as ResendState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as ResendState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is ResendState&&(identical(other.isSending, _this.isSending) || other.isSending == _this.isSending)&&(identical(other.sent, _this.sent) || other.sent == _this.sent)&&(identical(other.failure, _this.failure) || other.failure == _this.failure)&&(identical(other.emailError, _this.emailError) || other.emailError == _this.emailError)); +} + + +@override +int get hashCode { + final _this = this as ResendState; + return Object.hash(runtimeType,_this.isSending,_this.sent,_this.failure,_this.emailError); +} + +@override +String toString() { + final _this = this as ResendState; + return 'ResendState(isSending: ${_this.isSending}, sent: ${_this.sent}, failure: ${_this.failure}, emailError: ${_this.emailError})'; +} + + +} + +/// @nodoc +abstract mixin class $ResendStateCopyWith<$Res> { + factory $ResendStateCopyWith(ResendState value, $Res Function(ResendState) _then) = _$ResendStateCopyWithImpl; +@useResult +$Res call({ + bool isSending, bool sent, AuthFailure? failure, EmailError? emailError +}); + + +$AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class _$ResendStateCopyWithImpl<$Res> + implements $ResendStateCopyWith<$Res> { + _$ResendStateCopyWithImpl(this._self, this._then); + + final ResendState _self; + final $Res Function(ResendState) _then; + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isSending = null,Object? sent = null,Object? failure = freezed,Object? emailError = freezed,}) { + return _then(ResendState( +isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable +as bool,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as bool,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,emailError: freezed == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable +as EmailError?, + )); +} +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ResendState]. +extension ResendStatePatterns on ResendState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ResendState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ResendState value) $default,){ +final _that = this; +switch (_that) { +case _ResendState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ResendState value)? $default,){ +final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError) $default,) {final _that = this; +switch (_that) { +case _ResendState(): +return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError)? $default,) {final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ResendState implements ResendState { + const _ResendState({this.isSending = false, this.sent = false, this.failure, this.emailError}); + + +@override@JsonKey() final bool isSending; +/// The last request counts as sent (also for `emailSendLimited` from the +/// check-email screen, F12). +@override@JsonKey() final bool sent; +@override final AuthFailure? failure; +/// Only for an address typed in by the user. +@override final EmailError? emailError; + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ResendStateCopyWith<_ResendState> get copyWith => __$ResendStateCopyWithImpl<_ResendState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ResendState&&(identical(other.isSending, isSending) || other.isSending == isSending)&&(identical(other.sent, sent) || other.sent == sent)&&(identical(other.failure, failure) || other.failure == failure)&&(identical(other.emailError, emailError) || other.emailError == emailError)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,isSending,sent,failure,emailError); +} + +@override +String toString() { + return 'ResendState(isSending: $isSending, sent: $sent, failure: $failure, emailError: $emailError)'; +} + + +} + +/// @nodoc +abstract mixin class _$ResendStateCopyWith<$Res> implements $ResendStateCopyWith<$Res> { + factory _$ResendStateCopyWith(_ResendState value, $Res Function(_ResendState) _then) = __$ResendStateCopyWithImpl; +@override @useResult +$Res call({ + bool isSending, bool sent, AuthFailure? failure, EmailError? emailError +}); + + +@override $AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class __$ResendStateCopyWithImpl<$Res> + implements _$ResendStateCopyWith<$Res> { + __$ResendStateCopyWithImpl(this._self, this._then); + + final _ResendState _self; + final $Res Function(_ResendState) _then; + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isSending = null,Object? sent = null,Object? failure = freezed,Object? emailError = freezed,}) { + return _then(_ResendState( +isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable +as bool,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as bool,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,emailError: freezed == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable +as EmailError?, + )); +} + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/check_email/resend_controller.g.dart b/app/lib/features/auth/presentation/check_email/resend_controller.g.dart new file mode 100644 index 0000000..b342620 --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/resend_controller.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'resend_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). + +@ProviderFor(ResendController) +final resendControllerProvider = ResendControllerProvider._(); + +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). +final class ResendControllerProvider + extends $NotifierProvider { + /// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). + /// + /// The caller states where the request comes from: only there the controller + /// decides whether `emailSendLimited` is shown (login) or treated as success + /// (check-email screen). + ResendControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'resendControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$resendControllerHash(); + + @$internal + @override + ResendController create() => ResendController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ResendState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$resendControllerHash() => r'cab54661555bb641d3dc0fb1b06486b10f2e59e8'; + +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). + +abstract class _$ResendController extends $Notifier { + ResendState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ResendState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/email_link/email_link_controller.dart b/app/lib/features/auth/presentation/email_link/email_link_controller.dart new file mode 100644 index 0000000..85d5f1d --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_controller.dart @@ -0,0 +1,63 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/email_link_request.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +part 'email_link_controller.freezed.dart'; +part 'email_link_controller.g.dart'; + +enum EmailLinkPhase { + /// Waiting for the click. + ready, + submitting, + success, + + /// Link missing, invalid, expired or used. + invalid, +} + +@freezed +abstract class EmailLinkState with _$EmailLinkState { + const factory EmailLinkState({ + required EmailLinkPhase phase, + EmailLinkRequest? request, + + /// Failure that allows another try (network, rate limit, unexpected). + AuthFailure? failure, + }) = _EmailLinkState; +} + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). +@riverpod +class EmailLinkController extends _$EmailLinkController { + @override + EmailLinkState build(String? tokenHash, String? type) { + final request = EmailLinkRequest.tryParse(tokenHash, type); + return request == null + ? const EmailLinkState(phase: EmailLinkPhase.invalid) + : EmailLinkState(phase: EmailLinkPhase.ready, request: request); + } + + Future confirm() async { + final request = state.request; + if (request == null || state.phase != EmailLinkPhase.ready) { + return; + } + state = state.copyWith(phase: EmailLinkPhase.submitting, failure: null); + final result = await ref + .read(authRepositoryProvider) + .verifyEmailLink(request.tokenHash, request.type); + if (!ref.mounted) { + return; + } + final failure = result.errorOrNull; + state = switch (failure) { + null => state.copyWith(phase: EmailLinkPhase.success), + LinkInvalidOrExpired() => state.copyWith(phase: EmailLinkPhase.invalid), + _ => state.copyWith(phase: EmailLinkPhase.ready, failure: failure), + }; + } +} diff --git a/app/lib/features/auth/presentation/email_link/email_link_controller.freezed.dart b/app/lib/features/auth/presentation/email_link/email_link_controller.freezed.dart new file mode 100644 index 0000000..09bcba8 --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_controller.freezed.dart @@ -0,0 +1,311 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'email_link_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$EmailLinkState { + + EmailLinkPhase get phase; EmailLinkRequest? get request;/// Failure that allows another try (network, rate limit, unexpected). + AuthFailure? get failure; +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmailLinkStateCopyWith get copyWith => _$EmailLinkStateCopyWithImpl(this as EmailLinkState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as EmailLinkState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailLinkState&&(identical(other.phase, _this.phase) || other.phase == _this.phase)&&(identical(other.request, _this.request) || other.request == _this.request)&&(identical(other.failure, _this.failure) || other.failure == _this.failure)); +} + + +@override +int get hashCode { + final _this = this as EmailLinkState; + return Object.hash(runtimeType,_this.phase,_this.request,_this.failure); +} + +@override +String toString() { + final _this = this as EmailLinkState; + return 'EmailLinkState(phase: ${_this.phase}, request: ${_this.request}, failure: ${_this.failure})'; +} + + +} + +/// @nodoc +abstract mixin class $EmailLinkStateCopyWith<$Res> { + factory $EmailLinkStateCopyWith(EmailLinkState value, $Res Function(EmailLinkState) _then) = _$EmailLinkStateCopyWithImpl; +@useResult +$Res call({ + EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure +}); + + +$AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class _$EmailLinkStateCopyWithImpl<$Res> + implements $EmailLinkStateCopyWith<$Res> { + _$EmailLinkStateCopyWithImpl(this._self, this._then); + + final EmailLinkState _self; + final $Res Function(EmailLinkState) _then; + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? phase = null,Object? request = freezed,Object? failure = freezed,}) { + return _then(EmailLinkState( +phase: null == phase ? _self.phase : phase // ignore: cast_nullable_to_non_nullable +as EmailLinkPhase,request: freezed == request ? _self.request : request // ignore: cast_nullable_to_non_nullable +as EmailLinkRequest?,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?, + )); +} +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [EmailLinkState]. +extension EmailLinkStatePatterns on EmailLinkState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmailLinkState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmailLinkState value) $default,){ +final _that = this; +switch (_that) { +case _EmailLinkState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmailLinkState value)? $default,){ +final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that.phase,_that.request,_that.failure);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure) $default,) {final _that = this; +switch (_that) { +case _EmailLinkState(): +return $default(_that.phase,_that.request,_that.failure);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure)? $default,) {final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that.phase,_that.request,_that.failure);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _EmailLinkState implements EmailLinkState { + const _EmailLinkState({required this.phase, this.request, this.failure}); + + +@override final EmailLinkPhase phase; +@override final EmailLinkRequest? request; +/// Failure that allows another try (network, rate limit, unexpected). +@override final AuthFailure? failure; + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmailLinkStateCopyWith<_EmailLinkState> get copyWith => __$EmailLinkStateCopyWithImpl<_EmailLinkState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmailLinkState&&(identical(other.phase, phase) || other.phase == phase)&&(identical(other.request, request) || other.request == request)&&(identical(other.failure, failure) || other.failure == failure)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,phase,request,failure); +} + +@override +String toString() { + return 'EmailLinkState(phase: $phase, request: $request, failure: $failure)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmailLinkStateCopyWith<$Res> implements $EmailLinkStateCopyWith<$Res> { + factory _$EmailLinkStateCopyWith(_EmailLinkState value, $Res Function(_EmailLinkState) _then) = __$EmailLinkStateCopyWithImpl; +@override @useResult +$Res call({ + EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure +}); + + +@override $AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class __$EmailLinkStateCopyWithImpl<$Res> + implements _$EmailLinkStateCopyWith<$Res> { + __$EmailLinkStateCopyWithImpl(this._self, this._then); + + final _EmailLinkState _self; + final $Res Function(_EmailLinkState) _then; + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? phase = null,Object? request = freezed,Object? failure = freezed,}) { + return _then(_EmailLinkState( +phase: null == phase ? _self.phase : phase // ignore: cast_nullable_to_non_nullable +as EmailLinkPhase,request: freezed == request ? _self.request : request // ignore: cast_nullable_to_non_nullable +as EmailLinkRequest?,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?, + )); +} + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/email_link/email_link_controller.g.dart b/app/lib/features/auth/presentation/email_link/email_link_controller.g.dart new file mode 100644 index 0000000..6c93756 --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_controller.g.dart @@ -0,0 +1,130 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'email_link_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). + +@ProviderFor(EmailLinkController) +final emailLinkControllerProvider = EmailLinkControllerFamily._(); + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). +final class EmailLinkControllerProvider + extends $NotifierProvider { + /// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only + /// by [confirm] (a button press), never when the screen opens, so mail + /// scanners cannot use up the one-time token (Spec #1, E3). + EmailLinkControllerProvider._({ + required EmailLinkControllerFamily super.from, + required (String?, String?) super.argument, + }) : super( + retry: null, + name: r'emailLinkControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$emailLinkControllerHash(); + + @override + String toString() { + return r'emailLinkControllerProvider' + '' + '$argument'; + } + + @$internal + @override + EmailLinkController create() => EmailLinkController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EmailLinkState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is EmailLinkControllerProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$emailLinkControllerHash() => + r'90d3746ab8aa2ceb60122ee09c942cac6dd49249'; + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). + +final class EmailLinkControllerFamily extends $Family + with + $ClassFamilyOverride< + EmailLinkController, + EmailLinkState, + EmailLinkState, + EmailLinkState, + (String?, String?) + > { + EmailLinkControllerFamily._() + : super( + retry: null, + name: r'emailLinkControllerProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only + /// by [confirm] (a button press), never when the screen opens, so mail + /// scanners cannot use up the one-time token (Spec #1, E3). + + EmailLinkControllerProvider call(String? tokenHash, String? type) => + EmailLinkControllerProvider._(argument: (tokenHash, type), from: this); + + @override + String toString() => r'emailLinkControllerProvider'; +} + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). + +abstract class _$EmailLinkController extends $Notifier { + late final _$args = ref.$arg as (String?, String?); + String? get tokenHash => _$args.$1; + String? get type => _$args.$2; + + EmailLinkState build(String? tokenHash, String? type); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + EmailLinkState, + Object?, + Object? + >; + return element.handleCreate(ref, () => build(_$args.$1, _$args.$2)); + } +} diff --git a/app/lib/features/auth/presentation/email_link/email_link_screen.dart b/app/lib/features/auth/presentation/email_link/email_link_screen.dart new file mode 100644 index 0000000..ceef8a4 --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/platform/platform_info.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/email_link/email_link_controller.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// Landing page of the mail links (`/auth/confirm?token_hash=…&type=…`, +/// KTO-01 Kriterium 1 und 8). +/// +/// Redeeming needs a button press (Spec #1, E3): mail scanners that only open +/// the URL must not use up the one-time token. +class EmailLinkScreen extends ConsumerWidget { + const EmailLinkScreen({this.tokenHash, this.type, super.key}); + + final String? tokenHash; + final String? type; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = emailLinkControllerProvider(tokenHash, type); + final state = ref.watch(provider); + final controller = ref.read(provider.notifier); + final l10n = context.l10n; + final theme = Theme.of(context); + final linkType = state.request?.type ?? EmailLinkType.tryParse(type); + final isRecovery = linkType == EmailLinkType.recovery; + final title = isRecovery ? l10n.authRecoveryTitle : l10n.authConfirmTitle; + + final Widget content; + switch (state.phase) { + case EmailLinkPhase.invalid: + content = AppBanner( + key: const Key('email_link_invalid_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: l10n.authErrorLinkInvalidOrExpired, + actionLabel: l10n.authActionRequestNewLink, + onAction: () => context.go( + isRecovery ? AppRoutes.forgotPassword : AppRoutes.login, + ), + ); + case EmailLinkPhase.success: + content = _Success(isRecovery: isRecovery); + case EmailLinkPhase.ready || EmailLinkPhase.submitting: + final failure = state.failure; + content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (failure != null) ...[ + AppBanner( + key: const Key('email_link_error_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + ), + const SizedBox(height: AppSpacing.lg), + ], + Text( + isRecovery ? l10n.authRecoveryBody : l10n.authConfirmBody, + key: const Key('email_link_body'), + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('email_link_submit'), + label: isRecovery + ? l10n.authRecoverySubmit + : l10n.authConfirmSubmit, + loadingLabel: l10n.authSubmitting, + isLoading: state.phase == EmailLinkPhase.submitting, + onPressed: controller.confirm, + ), + ], + ); + } + + return AppFormScaffold( + key: const Key('email_link_screen'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(title), + const SizedBox(height: AppSpacing.lg), + content, + ], + ), + ); + } +} + +class _Success extends ConsumerWidget { + const _Success({required this.isRecovery}); + + final bool isRecovery; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final mobileWeb = ref.watch(isMobileWebProvider); + // Recovery continues automatically: the status change sends the user to + // the "new password" page (router redirect). + if (isRecovery) { + return const SizedBox.shrink(); + } + final message = mobileWeb + ? '${l10n.authConfirmSuccess} ${l10n.authConfirmSuccessMobileWeb}' + : l10n.authConfirmSuccess; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppBanner( + key: const Key('email_link_success_banner'), + type: AppBannerType.info, + typeLabel: l10n.authBannerTypeInfo, + message: message, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('email_link_continue'), + label: l10n.authConfirmContinue, + onPressed: () => context.go(AppRoutes.home), + ), + ], + ); + } +} diff --git a/app/lib/features/auth/presentation/providers/auth_providers.dart b/app/lib/features/auth/presentation/providers/auth_providers.dart index b35e4fe..b15d406 100644 --- a/app/lib/features/auth/presentation/providers/auth_providers.dart +++ b/app/lib/features/auth/presentation/providers/auth_providers.dart @@ -25,9 +25,13 @@ AuthRepository authRepository(Ref ref) { Stream authStatus(Ref ref) => ref.watch(authRepositoryProvider).watchStatus(); +Duration? _noRetry(int retryCount, Object error) => null; + /// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines -/// Passwortfeldes), nicht beim App-Start. -@Riverpod(keepAlive: true) +/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: +/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server +/// erzwingt sie ebenfalls), statt das Absenden zu blockieren. +@Riverpod(keepAlive: true, retry: _noRetry) Future commonPasswordList(Ref ref) => const AssetCommonPasswordList().load(); diff --git a/app/lib/features/auth/presentation/providers/auth_providers.g.dart b/app/lib/features/auth/presentation/providers/auth_providers.g.dart index 479f5d7..9792b65 100644 --- a/app/lib/features/auth/presentation/providers/auth_providers.g.dart +++ b/app/lib/features/auth/presentation/providers/auth_providers.g.dart @@ -94,13 +94,17 @@ final class AuthStatusProvider String _$authStatusHash() => r'4dc863db6fda2e17d84d0c3ed5c9bc1808630293'; /// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines -/// Passwortfeldes), nicht beim App-Start. +/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: +/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server +/// erzwingt sie ebenfalls), statt das Absenden zu blockieren. @ProviderFor(commonPasswordList) final commonPasswordListProvider = CommonPasswordListProvider._(); /// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines -/// Passwortfeldes), nicht beim App-Start. +/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: +/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server +/// erzwingt sie ebenfalls), statt das Absenden zu blockieren. final class CommonPasswordListProvider extends @@ -113,12 +117,14 @@ final class CommonPasswordListProvider $FutureModifier, $FutureProvider { /// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines - /// Passwortfeldes), nicht beim App-Start. + /// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: + /// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server + /// erzwingt sie ebenfalls), statt das Absenden zu blockieren. CommonPasswordListProvider._() : super( from: null, argument: null, - retry: null, + retry: _noRetry, name: r'commonPasswordListProvider', isAutoDispose: false, dependencies: null, @@ -141,7 +147,7 @@ final class CommonPasswordListProvider } String _$commonPasswordListHash() => - r'964e79d4dc152c38ca77a55919cef3c14e5d3533'; + r'39cdcf2bcde25bc7f4720c77feb7119febd2927f'; @ProviderFor(registrationInputFactory) final registrationInputFactoryProvider = RegistrationInputFactoryProvider._(); diff --git a/app/lib/features/auth/presentation/register/register_controller.dart b/app/lib/features/auth/presentation/register/register_controller.dart new file mode 100644 index 0000000..eac8bce --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_controller.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/config/app_config_provider.dart'; +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/register/register_state.dart'; + +part 'register_controller.g.dart'; + +/// Legal texts linked from the consent checkbox. +enum LegalDocument { privacy, terms } + +final _log = AppLogger('RegisterController'); + +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). +@riverpod +class RegisterController extends _$RegisterController { + Future? _commonPasswordsLoad; + + @override + RegisterState build() => const RegisterState(); + + void emailChanged(String value) { + state = state.copyWith(email: value, serverEmailInvalid: false); + } + + void passwordChanged(String value) { + // The password is checked while typing (Kriterium 3). + state = state.copyWith( + password: value, + visibleFields: {...state.visibleFields, RegisterField.password}, + serverPasswordReasons: null, + ); + unawaited(loadCommonPasswords()); + } + + void displayNameChanged(String value) { + state = state.copyWith(displayName: value); + } + + void ageConfirmedChanged({required bool value}) { + state = state.copyWith(ageConfirmed: value); + } + + void termsAcceptedChanged({required bool value}) { + state = state.copyWith(termsAccepted: value); + } + + /// Field left: show its error (text fields only). + void fieldBlurred(RegisterField field) { + if (field == RegisterField.ageConfirmation || + field == RegisterField.termsAcceptance) { + return; + } + state = state.copyWith(visibleFields: {...state.visibleFields, field}); + } + + /// Starts loading the list of common passwords (lazy, once). Called when + /// the password field is used. + Future loadCommonPasswords() => _commonPasswordsLoad ??= _load(); + + Future _load() async { + try { + final list = await ref.read(commonPasswordListProvider.future); + if (!ref.mounted) { + return; + } + state = state.copyWith(commonPasswords: list); + } on Object catch (error) { + // Fallback: only the length rule runs in the client; the server + // enforces the minimum length as well. + _log.warning('common password list unavailable: ${error.runtimeType}'); + } + } + + /// Validates and submits. Returns `true` if the screen should continue to + /// "check your inbox" (every 200 response, and `emailSendLimited`, F12). + Future submit() async { + if (state.isSubmitting) { + return false; + } + await loadCommonPasswords(); + if (!ref.mounted) { + return false; + } + + state = state.copyWith( + visibleFields: RegisterField.values.toSet(), + bannerFailure: null, + serverPasswordReasons: null, + serverEmailInvalid: false, + focusField: null, + ); + final invalid = state.firstInvalidField; + if (invalid != null) { + state = state.copyWith( + focusField: invalid, + focusRequest: state.focusRequest + 1, + ); + return false; + } + + state = state.copyWith(status: RegisterStatus.submitting); + final input = ref.read(registrationInputFactoryProvider)( + email: EmailFormat.normalize(state.email), + password: state.password, + displayName: DisplayNameRule.normalize(state.displayName), + ageConfirmed: state.ageConfirmed, + termsAccepted: state.termsAccepted, + ); + final result = await ref.read(authRepositoryProvider).signUp(input); + if (!ref.mounted) { + return false; + } + + final failure = result.errorOrNull; + state = state.copyWith(status: RegisterStatus.editing); + if (failure == null || openEmailInputFailureFor(failure) == null) { + return true; + } + _showFailure(failure); + return false; + } + + void _showFailure(AuthFailure failure) { + switch (failure) { + case WeakPassword(:final reasons): + state = state.copyWith( + serverPasswordReasons: reasons, + focusField: RegisterField.password, + focusRequest: state.focusRequest + 1, + ); + case InvalidEmail(): + state = state.copyWith( + serverEmailInvalid: true, + focusField: RegisterField.email, + focusRequest: state.focusRequest + 1, + ); + default: + state = state.copyWith(bannerFailure: failure); + } + } + + /// Opens a legal text in the browser. `false` if that did not work. + Future openLegalDocument(LegalDocument document) async { + final config = ref.read(appConfigProvider); + final url = switch (document) { + LegalDocument.privacy => config.legalPrivacyUrl, + LegalDocument.terms => config.legalTermsUrl, + }; + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme) { + _log.warning('invalid legal url for ${document.name}'); + return false; + } + return ref.read(urlOpenerProvider)(uri); + } +} diff --git a/app/lib/features/auth/presentation/register/register_controller.g.dart b/app/lib/features/auth/presentation/register/register_controller.g.dart new file mode 100644 index 0000000..07fe2a4 --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_controller.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'register_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). + +@ProviderFor(RegisterController) +final registerControllerProvider = RegisterControllerProvider._(); + +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). +final class RegisterControllerProvider + extends $NotifierProvider { + /// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). + RegisterControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'registerControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$registerControllerHash(); + + @$internal + @override + RegisterController create() => RegisterController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RegisterState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$registerControllerHash() => + r'25930050b4b5fbaff535c3a09abe4ca15c860759'; + +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). + +abstract class _$RegisterController extends $Notifier { + RegisterState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + RegisterState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/register/register_screen.dart b/app/lib/features/auth/presentation/register/register_screen.dart new file mode 100644 index 0000000..dd93095 --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_screen.dart @@ -0,0 +1,309 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_checkbox_field.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_password_field.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/register/register_controller.dart'; +import 'package:shuffle/features/auth/presentation/register/register_state.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// Registration (`/register`, KTO-01 Kriterien 1 bis 4 und 10, QUE-04 +/// Kriterium 1). Only shows state and forwards input to the +/// [RegisterController]. +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final _email = TextEditingController(); + final _password = TextEditingController(); + final _displayName = TextEditingController(); + final _emailFocus = FocusNode(); + final _passwordFocus = FocusNode(); + final _displayNameFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _emailFocus.addListener( + () => _onFocusChange(_emailFocus, RegisterField.email), + ); + _passwordFocus.addListener(() { + if (_passwordFocus.hasFocus) { + // The list of common passwords loads lazily on first use. + unawaited( + ref.read(registerControllerProvider.notifier).loadCommonPasswords(), + ); + } + _onFocusChange(_passwordFocus, RegisterField.password); + }); + _displayNameFocus.addListener( + () => _onFocusChange(_displayNameFocus, RegisterField.displayName), + ); + } + + void _onFocusChange(FocusNode node, RegisterField field) { + if (!node.hasFocus) { + ref.read(registerControllerProvider.notifier).fieldBlurred(field); + } + } + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + _displayName.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + _displayNameFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + final controller = ref.read(registerControllerProvider.notifier); + final succeeded = await controller.submit(); + if (!mounted || !succeeded) { + return; + } + final email = EmailFormat.normalize( + ref.read(registerControllerProvider).email, + ); + TextInput.finishAutofillContext(); + context.go(AppRoutes.registerCheckEmail, extra: email); + } + + Future _openLegal(LegalDocument document) async { + final opened = await ref + .read(registerControllerProvider.notifier) + .openLegalDocument(document); + if (!mounted || opened) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.authErrorUnexpected))); + } + + void _requestFocus(RegisterField? field) { + final node = switch (field) { + RegisterField.email => _emailFocus, + RegisterField.password => _passwordFocus, + RegisterField.displayName => _displayNameFocus, + _ => null, + }; + node?.requestFocus(); + } + + @override + Widget build(BuildContext context) { + ref.listen(registerControllerProvider, (previous, next) { + if (previous?.focusRequest != next.focusRequest) { + _requestFocus(next.focusField); + } + }); + final state = ref.watch(registerControllerProvider); + final controller = ref.read(registerControllerProvider.notifier); + final l10n = context.l10n; + final busy = state.isSubmitting; + + return AppFormScaffold( + key: const Key('register_screen'), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(l10n.authRegisterTitle), + if (state.bannerFailure case final failure?) ...[ + const SizedBox(height: AppSpacing.lg), + _FailureBanner( + key: const Key('register_banner'), + failure: failure, + onRetry: busy ? null : _submit, + ), + ], + const SizedBox(height: AppSpacing.xl), + AppTextField( + key: const Key('register_email'), + label: l10n.authFieldEmailLabel, + controller: _email, + focusNode: _emailFocus, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + autocorrect: false, + enableSuggestions: false, + enabled: !busy, + errorText: _emailError(l10n, state), + onChanged: controller.emailChanged, + ), + const SizedBox(height: AppSpacing.lg), + AppPasswordField( + key: const Key('register_password'), + label: l10n.authFieldPasswordLabel, + showPasswordLabel: l10n.authPasswordShow, + hidePasswordLabel: l10n.authPasswordHide, + controller: _password, + focusNode: _passwordFocus, + isNewPassword: true, + textInputAction: TextInputAction.next, + enabled: !busy, + ruleHint: l10n.authPasswordRule, + errorText: _passwordError(l10n, state), + onChanged: controller.passwordChanged, + ), + const SizedBox(height: AppSpacing.lg), + AppTextField( + key: const Key('register_display_name'), + label: l10n.authFieldDisplayNameLabel, + controller: _displayName, + focusNode: _displayNameFocus, + textInputAction: TextInputAction.done, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.nickname], + maxLength: DisplayNameRule.maxLength, + showCounter: true, + counterText: l10n.authDisplayNameCounter( + state.displayNameLength, + DisplayNameRule.maxLength, + ), + enabled: !busy, + errorText: _displayNameError(l10n, state), + onChanged: controller.displayNameChanged, + onSubmitted: (_) { + if (!busy) { + unawaited(_submit()); + } + }, + ), + const SizedBox(height: AppSpacing.lg), + AppCheckboxField( + key: const Key('register_age_checkbox'), + value: state.ageConfirmed, + label: l10n.authCheckboxAgeLabel, + enabled: !busy, + errorText: state.shownAgeError ? l10n.authAgeNotConfirmed : null, + onChanged: (value) => + controller.ageConfirmedChanged(value: value), + ), + AppCheckboxField.rich( + key: const Key('register_terms_checkbox'), + value: state.termsAccepted, + enabled: !busy, + segments: _termsSegments(l10n), + errorText: state.shownTermsError + ? l10n.authTermsNotAccepted + : null, + onChanged: (value) => + controller.termsAcceptedChanged(value: value), + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('register_submit'), + label: l10n.authRegisterSubmit, + loadingLabel: l10n.authSubmitting, + isLoading: busy, + onPressed: _submit, + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + key: const Key('register_login_link'), + onPressed: busy ? null : () => context.go(AppRoutes.login), + child: Text(l10n.authRegisterHaveAccount), + ), + ], + ), + ), + ); + } + + List _termsSegments(AppLocalizations l10n) { + final suffix = l10n.authCheckboxTermsSuffix; + return [ + AppLabelSegment.text('${l10n.authCheckboxTermsPrefix} '), + AppLabelSegment.link( + l10n.authCheckboxTermsPrivacyLink, + () => _openLegal(LegalDocument.privacy), + ), + AppLabelSegment.text(' ${l10n.authCheckboxTermsMiddle} '), + AppLabelSegment.link( + l10n.authCheckboxTermsTermsLink, + () => _openLegal(LegalDocument.terms), + ), + if (suffix.isNotEmpty) AppLabelSegment.text(' $suffix'), + ]; + } + + String? _emailError(AppLocalizations l10n, RegisterState state) { + if (state.serverEmailInvalid || state.shownEmailError != null) { + return l10n.authErrorEmailInvalid; + } + return null; + } + + String? _passwordError(AppLocalizations l10n, RegisterState state) { + if (state.serverPasswordReasons case final reasons?) { + return weakPasswordMessage(l10n, reasons); + } + if (state.shownPasswordRequired) { + return l10n.authPasswordRequired; + } + return switch (state.shownPasswordError) { + PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing), + PasswordCommon() => l10n.authPasswordCommon, + null => null, + }; + } + + String? _displayNameError(AppLocalizations l10n, RegisterState state) => + switch (state.shownDisplayNameError) { + DisplayNameError.empty => l10n.authDisplayNameEmpty, + DisplayNameError.tooLong => l10n.authDisplayNameTooLong( + DisplayNameRule.maxLength, + ), + // U+200B: same message as "invalid" (Spec #1 § 5.4). + DisplayNameError.invalidCharacter => l10n.authDisplayNameInvalid, + null => null, + }; +} + +class _FailureBanner extends StatelessWidget { + const _FailureBanner({ + required this.failure, + required this.onRetry, + super.key, + }); + + final AuthFailure failure; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final retryable = failure is NetworkFailure || failure is Unexpected; + return AppBanner( + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + actionLabel: retryable && onRetry != null ? l10n.authActionRetry : null, + onAction: retryable ? onRetry : null, + ); + } +} diff --git a/app/lib/features/auth/presentation/register/register_state.dart b/app/lib/features/auth/presentation/register/register_state.dart new file mode 100644 index 0000000..0005b68 --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_state.dart @@ -0,0 +1,115 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; + +part 'register_state.freezed.dart'; + +/// Form fields of the registration, in reading order. +enum RegisterField { + email, + password, + displayName, + ageConfirmation, + termsAcceptance, +} + +enum RegisterStatus { editing, submitting } + +/// Values, visibility of errors and submit state of the registration form. +/// +/// Errors are derived from the values by the domain rules. Which of them are +/// shown is decided by [visibleFields] (blur, typing in the password field, +/// submit attempt). +@freezed +abstract class RegisterState with _$RegisterState { + const factory RegisterState({ + @Default('') String email, + @Default('') String password, + @Default('') String displayName, + @Default(false) bool ageConfirmed, + @Default(false) bool termsAccepted, + @Default({}) Set visibleFields, + @Default(RegisterStatus.editing) RegisterStatus status, + + /// Loaded lazily with the first use of the password field. + CommonPasswordList? commonPasswords, + + /// General failure shown as banner. + AuthFailure? bannerFailure, + + /// `weak_password` reasons reported by the server, shown at the password. + List? serverPasswordReasons, + + /// The server rejected the address syntax; shown at the email field. + @Default(false) bool serverEmailInvalid, + + /// Field that should receive focus after a failed submit. + RegisterField? focusField, + + /// Incremented whenever [focusField] is set, so the screen can react + /// to repeated requests for the same field. + @Default(0) int focusRequest, + }) = _RegisterState; + + const RegisterState._(); + + bool get isSubmitting => status == RegisterStatus.submitting; + + EmailError? get emailError => EmailFormat.validate(email); + + PasswordError? get passwordError => + PasswordPolicy.validate(password, common: commonPasswords); + + DisplayNameError? get displayNameError => + DisplayNameRule.validate(displayName); + + int get displayNameLength => DisplayNameRule.length(displayName); + + bool get ageError => !ageConfirmed; + + bool get termsError => !termsAccepted; + + bool isVisible(RegisterField field) => visibleFields.contains(field); + + /// Local error of [field] (ignoring visibility). + bool hasError(RegisterField field) => switch (field) { + RegisterField.email => emailError != null, + RegisterField.password => password.isEmpty || passwordError != null, + RegisterField.displayName => displayNameError != null, + RegisterField.ageConfirmation => ageError, + RegisterField.termsAcceptance => termsError, + }; + + RegisterField? get firstInvalidField { + for (final field in RegisterField.values) { + if (hasError(field)) { + return field; + } + } + return null; + } + + // Errors as the screen shows them. + + EmailError? get shownEmailError => + isVisible(RegisterField.email) ? emailError : null; + + /// Empty password is "required", otherwise the policy error. + bool get shownPasswordRequired => + isVisible(RegisterField.password) && password.isEmpty; + + PasswordError? get shownPasswordError => + isVisible(RegisterField.password) ? passwordError : null; + + DisplayNameError? get shownDisplayNameError => + isVisible(RegisterField.displayName) ? displayNameError : null; + + bool get shownAgeError => + isVisible(RegisterField.ageConfirmation) && ageError; + + bool get shownTermsError => + isVisible(RegisterField.termsAcceptance) && termsError; +} diff --git a/app/lib/features/auth/presentation/register/register_state.freezed.dart b/app/lib/features/auth/presentation/register/register_state.freezed.dart new file mode 100644 index 0000000..3051f2a --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_state.freezed.dart @@ -0,0 +1,368 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'register_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RegisterState { + + String get email; String get password; String get displayName; bool get ageConfirmed; bool get termsAccepted; Set get visibleFields; RegisterStatus get status;/// Loaded lazily with the first use of the password field. + CommonPasswordList? get commonPasswords;/// General failure shown as banner. + AuthFailure? get bannerFailure;/// `weak_password` reasons reported by the server, shown at the password. + List? get serverPasswordReasons;/// The server rejected the address syntax; shown at the email field. + bool get serverEmailInvalid;/// Field that should receive focus after a failed submit. + RegisterField? get focusField;/// Incremented whenever [focusField] is set, so the screen can react +/// to repeated requests for the same field. + int get focusRequest; +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RegisterStateCopyWith get copyWith => _$RegisterStateCopyWithImpl(this as RegisterState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as RegisterState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is RegisterState&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.password, _this.password) || other.password == _this.password)&&(identical(other.displayName, _this.displayName) || other.displayName == _this.displayName)&&(identical(other.ageConfirmed, _this.ageConfirmed) || other.ageConfirmed == _this.ageConfirmed)&&(identical(other.termsAccepted, _this.termsAccepted) || other.termsAccepted == _this.termsAccepted)&&const DeepCollectionEquality().equals(other.visibleFields, _this.visibleFields)&&(identical(other.status, _this.status) || other.status == _this.status)&&(identical(other.commonPasswords, _this.commonPasswords) || other.commonPasswords == _this.commonPasswords)&&(identical(other.bannerFailure, _this.bannerFailure) || other.bannerFailure == _this.bannerFailure)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _this.serverPasswordReasons)&&(identical(other.serverEmailInvalid, _this.serverEmailInvalid) || other.serverEmailInvalid == _this.serverEmailInvalid)&&(identical(other.focusField, _this.focusField) || other.focusField == _this.focusField)&&(identical(other.focusRequest, _this.focusRequest) || other.focusRequest == _this.focusRequest)); +} + + +@override +int get hashCode { + final _this = this as RegisterState; + return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.commonPasswords,_this.bannerFailure,const DeepCollectionEquality().hash(_this.serverPasswordReasons),_this.serverEmailInvalid,_this.focusField,_this.focusRequest); +} + +@override +String toString() { + final _this = this as RegisterState; + return 'RegisterState(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, visibleFields: ${_this.visibleFields}, status: ${_this.status}, commonPasswords: ${_this.commonPasswords}, bannerFailure: ${_this.bannerFailure}, serverPasswordReasons: ${_this.serverPasswordReasons}, serverEmailInvalid: ${_this.serverEmailInvalid}, focusField: ${_this.focusField}, focusRequest: ${_this.focusRequest})'; +} + + +} + +/// @nodoc +abstract mixin class $RegisterStateCopyWith<$Res> { + factory $RegisterStateCopyWith(RegisterState value, $Res Function(RegisterState) _then) = _$RegisterStateCopyWithImpl; +@useResult +$Res call({ + String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest +}); + + +$AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class _$RegisterStateCopyWithImpl<$Res> + implements $RegisterStateCopyWith<$Res> { + _$RegisterStateCopyWithImpl(this._self, this._then); + + final RegisterState _self; + final $Res Function(RegisterState) _then; + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? visibleFields = null,Object? status = null,Object? commonPasswords = freezed,Object? bannerFailure = freezed,Object? serverPasswordReasons = freezed,Object? serverEmailInvalid = null,Object? focusField = freezed,Object? focusRequest = null,}) { + return _then(RegisterState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable +as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable +as bool,visibleFields: null == visibleFields ? _self.visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable +as Set,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as RegisterStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable +as CommonPasswordList?,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,serverPasswordReasons: freezed == serverPasswordReasons ? _self.serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable +as List?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable +as bool,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable +as RegisterField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [RegisterState]. +extension RegisterStatePatterns on RegisterState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RegisterState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RegisterState value) $default,){ +final _that = this; +switch (_that) { +case _RegisterState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RegisterState value)? $default,){ +final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest) $default,) {final _that = this; +switch (_that) { +case _RegisterState(): +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest)? $default,) {final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RegisterState extends RegisterState { + const _RegisterState({this.email = '', this.password = '', this.displayName = '', this.ageConfirmed = false, this.termsAccepted = false, Set visibleFields = const {}, this.status = RegisterStatus.editing, this.commonPasswords, this.bannerFailure, List? serverPasswordReasons, this.serverEmailInvalid = false, this.focusField, this.focusRequest = 0}): _visibleFields = visibleFields,_serverPasswordReasons = serverPasswordReasons,super._(); + + +@override@JsonKey() final String email; +@override@JsonKey() final String password; +@override@JsonKey() final String displayName; +@override@JsonKey() final bool ageConfirmed; +@override@JsonKey() final bool termsAccepted; + final Set _visibleFields; +@override@JsonKey() Set get visibleFields { + if (_visibleFields is EqualUnmodifiableSetView) return _visibleFields; + // ignore: implicit_dynamic_type + return EqualUnmodifiableSetView(_visibleFields); +} + +@override@JsonKey() final RegisterStatus status; +/// Loaded lazily with the first use of the password field. +@override final CommonPasswordList? commonPasswords; +/// General failure shown as banner. +@override final AuthFailure? bannerFailure; +/// `weak_password` reasons reported by the server, shown at the password. + final List? _serverPasswordReasons; +/// `weak_password` reasons reported by the server, shown at the password. +@override List? get serverPasswordReasons { + final value = _serverPasswordReasons; + if (value == null) return null; + if (_serverPasswordReasons is EqualUnmodifiableListView) return _serverPasswordReasons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +/// The server rejected the address syntax; shown at the email field. +@override@JsonKey() final bool serverEmailInvalid; +/// Field that should receive focus after a failed submit. +@override final RegisterField? focusField; +/// Incremented whenever [focusField] is set, so the screen can react +/// to repeated requests for the same field. +@override@JsonKey() final int focusRequest; + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RegisterStateCopyWith<_RegisterState> get copyWith => __$RegisterStateCopyWithImpl<_RegisterState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RegisterState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.ageConfirmed, ageConfirmed) || other.ageConfirmed == ageConfirmed)&&(identical(other.termsAccepted, termsAccepted) || other.termsAccepted == termsAccepted)&&const DeepCollectionEquality().equals(other.visibleFields, _visibleFields)&&(identical(other.status, status) || other.status == status)&&(identical(other.commonPasswords, commonPasswords) || other.commonPasswords == commonPasswords)&&(identical(other.bannerFailure, bannerFailure) || other.bannerFailure == bannerFailure)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _serverPasswordReasons)&&(identical(other.serverEmailInvalid, serverEmailInvalid) || other.serverEmailInvalid == serverEmailInvalid)&&(identical(other.focusField, focusField) || other.focusField == focusField)&&(identical(other.focusRequest, focusRequest) || other.focusRequest == focusRequest)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,const DeepCollectionEquality().hash(_visibleFields),status,commonPasswords,bannerFailure,const DeepCollectionEquality().hash(_serverPasswordReasons),serverEmailInvalid,focusField,focusRequest); +} + +@override +String toString() { + return 'RegisterState(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, visibleFields: $visibleFields, status: $status, commonPasswords: $commonPasswords, bannerFailure: $bannerFailure, serverPasswordReasons: $serverPasswordReasons, serverEmailInvalid: $serverEmailInvalid, focusField: $focusField, focusRequest: $focusRequest)'; +} + + +} + +/// @nodoc +abstract mixin class _$RegisterStateCopyWith<$Res> implements $RegisterStateCopyWith<$Res> { + factory _$RegisterStateCopyWith(_RegisterState value, $Res Function(_RegisterState) _then) = __$RegisterStateCopyWithImpl; +@override @useResult +$Res call({ + String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest +}); + + +@override $AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class __$RegisterStateCopyWithImpl<$Res> + implements _$RegisterStateCopyWith<$Res> { + __$RegisterStateCopyWithImpl(this._self, this._then); + + final _RegisterState _self; + final $Res Function(_RegisterState) _then; + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? visibleFields = null,Object? status = null,Object? commonPasswords = freezed,Object? bannerFailure = freezed,Object? serverPasswordReasons = freezed,Object? serverEmailInvalid = null,Object? focusField = freezed,Object? focusRequest = null,}) { + return _then(_RegisterState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable +as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable +as bool,visibleFields: null == visibleFields ? _self._visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable +as Set,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as RegisterStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable +as CommonPasswordList?,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,serverPasswordReasons: freezed == serverPasswordReasons ? _self._serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable +as List?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable +as bool,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable +as RegisterField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/widgets/auth_screen_title.dart b/app/lib/features/auth/presentation/widgets/auth_screen_title.dart new file mode 100644 index 0000000..5125887 --- /dev/null +++ b/app/lib/features/auth/presentation/widgets/auth_screen_title.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// Screen title as heading for screen readers (design: `headlineSmall`). +class AuthScreenTitle extends StatelessWidget { + const AuthScreenTitle(this.title, {super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Semantics( + header: true, + child: Text(title, style: Theme.of(context).textTheme.headlineSmall), + ); + } +} diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index d71ccac..5b12c86 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -4,5 +4,64 @@ "homePlaceholderMessage": "Willkommen bei Shuffle. Der Brettspielkatalog folgt in Kürze.", "notFoundTitle": "Seite nicht gefunden", "notFoundMessage": "Diese Seite existiert nicht oder wurde verschoben.", - "notFoundBackHome": "Zurück zum Start" + "notFoundBackHome": "Zurück zum Start", + "authErrorInvalidCredentials": "E-Mail-Adresse oder Passwort ist nicht korrekt.", + "authErrorEmailNotConfirmed": "Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt.", + "authActionResendConfirmation": "Bestätigungs-E-Mail erneut senden", + "authErrorRateLimited": "Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.", + "authErrorWeakPasswordLength": "Das Passwort ist zu kurz.", + "authErrorWeakPasswordCharacters": "Das Passwort ist zu einfach. Wähle ein anderes.", + "authErrorWeakPasswordPwned": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.", + "authErrorSamePassword": "Wähle ein anderes als dein bisheriges Passwort.", + "authErrorLinkInvalidOrExpired": "Der Link ist abgelaufen oder wurde schon benutzt.", + "authActionRequestNewLink": "Neuen Link anfordern", + "authErrorEmailInvalid": "Gib eine gültige E-Mail-Adresse ein.", + "authErrorRegistrationRejected": "Registrierung nicht möglich. Bitte prüfe deine Angaben.", + "authErrorNetwork": "Keine Verbindung. Für diesen Schritt brauchst du Internet.", + "authErrorUnexpected": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "authActionRetry": "Erneut versuchen", + "authPasswordTooShort": "Mindestens 10 Zeichen – es fehlen noch {missing}.", + "authPasswordCommon": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.", + "authPasswordRule": "Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.", + "authPasswordRequired": "Gib ein Passwort ein.", + "authDisplayNameEmpty": "Gib einen Anzeigenamen ein.", + "authDisplayNameTooLong": "Der Anzeigename darf höchstens {max} Zeichen lang sein.", + "authDisplayNameInvalid": "Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen.", + "authDisplayNameCounter": "{count}/{max}", + "authAgeNotConfirmed": "Shuffle ist erst ab 16 Jahren nutzbar.", + "authTermsNotAccepted": "Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.", + "authFieldEmailLabel": "E-Mail-Adresse", + "authFieldPasswordLabel": "Passwort", + "authFieldDisplayNameLabel": "Anzeigename", + "authPasswordShow": "Passwort anzeigen", + "authPasswordHide": "Passwort verbergen", + "authCheckboxAgeLabel": "Ich bin mindestens 16 Jahre alt", + "authCheckboxTermsPrefix": "Ich habe die", + "authCheckboxTermsPrivacyLink": "Datenschutzerklärung", + "authCheckboxTermsMiddle": "gelesen und stimme den", + "authCheckboxTermsTermsLink": "Nutzungsbedingungen", + "authCheckboxTermsSuffix": "zu", + "authBannerTypeError": "Fehler", + "authBannerTypeInfo": "Hinweis", + "authSubmitting": "Bitte warten …", + "authRegisterTitle": "Konto erstellen", + "authRegisterSubmit": "Konto erstellen", + "authRegisterHaveAccount": "Schon ein Konto? Anmelden", + "authCheckEmailTitle": "Prüfe dein Postfach", + "authCheckEmailBody": "Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.", + "authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.", + "authCheckEmailValidity": "Der Link ist {duration} gültig.", + "authCheckEmailValidityOneHour": "1 Stunde", + "authCheckEmailResend": "E-Mail erneut senden", + "authCheckEmailResent": "Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt.", + "authBackToLogin": "Zurück zur Anmeldung", + "authConfirmTitle": "E-Mail-Adresse bestätigen", + "authConfirmBody": "Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen.", + "authConfirmSubmit": "E-Mail-Adresse bestätigen", + "authConfirmSuccess": "E-Mail bestätigt.", + "authConfirmSuccessMobileWeb": "Du kannst dich jetzt auch in der App anmelden.", + "authConfirmContinue": "Weiter", + "authRecoveryTitle": "Neues Passwort festlegen", + "authRecoveryBody": "Ein Klick genügt, um ein neues Passwort festzulegen.", + "authRecoverySubmit": "Weiter zum neuen Passwort" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 938ef0f..b374bac 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -19,5 +19,95 @@ "notFoundBackHome": "Back to start", "@notFoundBackHome": { "description": "Button on the 404 page that navigates to the start page" - } + }, + "authErrorInvalidCredentials": "Email address or password is incorrect.", + "authErrorEmailNotConfirmed": "Please confirm your email address first. We sent you an email for this.", + "authActionResendConfirmation": "Resend confirmation email", + "authErrorRateLimited": "Too many attempts. Please wait a few minutes and try again.", + "authErrorWeakPasswordLength": "The password is too short.", + "authErrorWeakPasswordCharacters": "The password is too simple. Choose a different one.", + "authErrorWeakPasswordPwned": "This password is very common and easy to guess. Choose a different one.", + "authErrorSamePassword": "Choose a password different from your current one.", + "authErrorLinkInvalidOrExpired": "The link has expired or has already been used.", + "authActionRequestNewLink": "Request a new link", + "authErrorEmailInvalid": "Enter a valid email address.", + "authErrorRegistrationRejected": "Registration is not possible. Please check your details.", + "authErrorNetwork": "No connection. You need internet access for this step.", + "authErrorUnexpected": "Something went wrong. Please try again.", + "authActionRetry": "Try again", + "authPasswordTooShort": "At least 10 characters – {missing} more needed.", + "@authPasswordTooShort": { + "placeholders": { + "missing": { + "type": "int" + } + } + }, + "authPasswordCommon": "This password is very common and easy to guess. Choose a different one.", + "authPasswordRule": "At least 10 characters. Not a very common password.", + "authPasswordRequired": "Enter a password.", + "authDisplayNameEmpty": "Enter a display name.", + "authDisplayNameTooLong": "The display name can be at most {max} characters long.", + "@authDisplayNameTooLong": { + "placeholders": { + "max": { + "type": "int" + } + } + }, + "authDisplayNameInvalid": "This display name is not allowed. Use visible characters.", + "authDisplayNameCounter": "{count}/{max}", + "@authDisplayNameCounter": { + "placeholders": { + "count": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "authAgeNotConfirmed": "Shuffle can only be used from age 16.", + "authTermsNotAccepted": "Please agree to the privacy policy and the terms of use.", + "authFieldEmailLabel": "Email address", + "authFieldPasswordLabel": "Password", + "authFieldDisplayNameLabel": "Display name", + "authPasswordShow": "Show password", + "authPasswordHide": "Hide password", + "authCheckboxAgeLabel": "I am at least 16 years old", + "authCheckboxTermsPrefix": "I have read the", + "authCheckboxTermsPrivacyLink": "privacy policy", + "authCheckboxTermsMiddle": "and agree to the", + "authCheckboxTermsTermsLink": "terms of use", + "authCheckboxTermsSuffix": "", + "authBannerTypeError": "Error", + "authBannerTypeInfo": "Note", + "authSubmitting": "Please wait …", + "authRegisterTitle": "Create account", + "authRegisterSubmit": "Create account", + "authRegisterHaveAccount": "Already have an account? Sign in", + "authCheckEmailTitle": "Check your inbox", + "authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.", + "authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.", + "authCheckEmailValidity": "The link is valid for {duration}.", + "@authCheckEmailValidity": { + "placeholders": { + "duration": { + "type": "String" + } + } + }, + "authCheckEmailValidityOneHour": "1 hour", + "authCheckEmailResend": "Resend email", + "authCheckEmailResent": "We resent the email, if there is something to send for this address.", + "authBackToLogin": "Back to sign in", + "authConfirmTitle": "Confirm email address", + "authConfirmBody": "One click is enough to confirm your email address.", + "authConfirmSubmit": "Confirm email address", + "authConfirmSuccess": "Email confirmed.", + "authConfirmSuccessMobileWeb": "You can now also sign in in the app.", + "authConfirmContinue": "Continue", + "authRecoveryTitle": "Set a new password", + "authRecoveryBody": "One click is enough to set a new password.", + "authRecoverySubmit": "Continue to new password" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 989bd53..3971f2a 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -127,6 +127,360 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Back to start'** String get notFoundBackHome; + + /// No description provided for @authErrorInvalidCredentials. + /// + /// In en, this message translates to: + /// **'Email address or password is incorrect.'** + String get authErrorInvalidCredentials; + + /// No description provided for @authErrorEmailNotConfirmed. + /// + /// In en, this message translates to: + /// **'Please confirm your email address first. We sent you an email for this.'** + String get authErrorEmailNotConfirmed; + + /// No description provided for @authActionResendConfirmation. + /// + /// In en, this message translates to: + /// **'Resend confirmation email'** + String get authActionResendConfirmation; + + /// No description provided for @authErrorRateLimited. + /// + /// In en, this message translates to: + /// **'Too many attempts. Please wait a few minutes and try again.'** + String get authErrorRateLimited; + + /// No description provided for @authErrorWeakPasswordLength. + /// + /// In en, this message translates to: + /// **'The password is too short.'** + String get authErrorWeakPasswordLength; + + /// No description provided for @authErrorWeakPasswordCharacters. + /// + /// In en, this message translates to: + /// **'The password is too simple. Choose a different one.'** + String get authErrorWeakPasswordCharacters; + + /// No description provided for @authErrorWeakPasswordPwned. + /// + /// In en, this message translates to: + /// **'This password is very common and easy to guess. Choose a different one.'** + String get authErrorWeakPasswordPwned; + + /// No description provided for @authErrorSamePassword. + /// + /// In en, this message translates to: + /// **'Choose a password different from your current one.'** + String get authErrorSamePassword; + + /// No description provided for @authErrorLinkInvalidOrExpired. + /// + /// In en, this message translates to: + /// **'The link has expired or has already been used.'** + String get authErrorLinkInvalidOrExpired; + + /// No description provided for @authActionRequestNewLink. + /// + /// In en, this message translates to: + /// **'Request a new link'** + String get authActionRequestNewLink; + + /// No description provided for @authErrorEmailInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid email address.'** + String get authErrorEmailInvalid; + + /// No description provided for @authErrorRegistrationRejected. + /// + /// In en, this message translates to: + /// **'Registration is not possible. Please check your details.'** + String get authErrorRegistrationRejected; + + /// No description provided for @authErrorNetwork. + /// + /// In en, this message translates to: + /// **'No connection. You need internet access for this step.'** + String get authErrorNetwork; + + /// No description provided for @authErrorUnexpected. + /// + /// In en, this message translates to: + /// **'Something went wrong. Please try again.'** + String get authErrorUnexpected; + + /// No description provided for @authActionRetry. + /// + /// In en, this message translates to: + /// **'Try again'** + String get authActionRetry; + + /// No description provided for @authPasswordTooShort. + /// + /// In en, this message translates to: + /// **'At least 10 characters – {missing} more needed.'** + String authPasswordTooShort(int missing); + + /// No description provided for @authPasswordCommon. + /// + /// In en, this message translates to: + /// **'This password is very common and easy to guess. Choose a different one.'** + String get authPasswordCommon; + + /// No description provided for @authPasswordRule. + /// + /// In en, this message translates to: + /// **'At least 10 characters. Not a very common password.'** + String get authPasswordRule; + + /// No description provided for @authPasswordRequired. + /// + /// In en, this message translates to: + /// **'Enter a password.'** + String get authPasswordRequired; + + /// No description provided for @authDisplayNameEmpty. + /// + /// In en, this message translates to: + /// **'Enter a display name.'** + String get authDisplayNameEmpty; + + /// No description provided for @authDisplayNameTooLong. + /// + /// In en, this message translates to: + /// **'The display name can be at most {max} characters long.'** + String authDisplayNameTooLong(int max); + + /// No description provided for @authDisplayNameInvalid. + /// + /// In en, this message translates to: + /// **'This display name is not allowed. Use visible characters.'** + String get authDisplayNameInvalid; + + /// No description provided for @authDisplayNameCounter. + /// + /// In en, this message translates to: + /// **'{count}/{max}'** + String authDisplayNameCounter(int count, int max); + + /// No description provided for @authAgeNotConfirmed. + /// + /// In en, this message translates to: + /// **'Shuffle can only be used from age 16.'** + String get authAgeNotConfirmed; + + /// No description provided for @authTermsNotAccepted. + /// + /// In en, this message translates to: + /// **'Please agree to the privacy policy and the terms of use.'** + String get authTermsNotAccepted; + + /// No description provided for @authFieldEmailLabel. + /// + /// In en, this message translates to: + /// **'Email address'** + String get authFieldEmailLabel; + + /// No description provided for @authFieldPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password'** + String get authFieldPasswordLabel; + + /// No description provided for @authFieldDisplayNameLabel. + /// + /// In en, this message translates to: + /// **'Display name'** + String get authFieldDisplayNameLabel; + + /// No description provided for @authPasswordShow. + /// + /// In en, this message translates to: + /// **'Show password'** + String get authPasswordShow; + + /// No description provided for @authPasswordHide. + /// + /// In en, this message translates to: + /// **'Hide password'** + String get authPasswordHide; + + /// No description provided for @authCheckboxAgeLabel. + /// + /// In en, this message translates to: + /// **'I am at least 16 years old'** + String get authCheckboxAgeLabel; + + /// No description provided for @authCheckboxTermsPrefix. + /// + /// In en, this message translates to: + /// **'I have read the'** + String get authCheckboxTermsPrefix; + + /// No description provided for @authCheckboxTermsPrivacyLink. + /// + /// In en, this message translates to: + /// **'privacy policy'** + String get authCheckboxTermsPrivacyLink; + + /// No description provided for @authCheckboxTermsMiddle. + /// + /// In en, this message translates to: + /// **'and agree to the'** + String get authCheckboxTermsMiddle; + + /// No description provided for @authCheckboxTermsTermsLink. + /// + /// In en, this message translates to: + /// **'terms of use'** + String get authCheckboxTermsTermsLink; + + /// No description provided for @authCheckboxTermsSuffix. + /// + /// In en, this message translates to: + /// **''** + String get authCheckboxTermsSuffix; + + /// No description provided for @authBannerTypeError. + /// + /// In en, this message translates to: + /// **'Error'** + String get authBannerTypeError; + + /// No description provided for @authBannerTypeInfo. + /// + /// In en, this message translates to: + /// **'Note'** + String get authBannerTypeInfo; + + /// No description provided for @authSubmitting. + /// + /// In en, this message translates to: + /// **'Please wait …'** + String get authSubmitting; + + /// No description provided for @authRegisterTitle. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authRegisterTitle; + + /// No description provided for @authRegisterSubmit. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authRegisterSubmit; + + /// No description provided for @authRegisterHaveAccount. + /// + /// In en, this message translates to: + /// **'Already have an account? Sign in'** + String get authRegisterHaveAccount; + + /// No description provided for @authCheckEmailTitle. + /// + /// In en, this message translates to: + /// **'Check your inbox'** + String get authCheckEmailTitle; + + /// No description provided for @authCheckEmailBody. + /// + /// In en, this message translates to: + /// **'We sent you an email if the address is not registered yet. Follow the link in it to continue.'** + String get authCheckEmailBody; + + /// No description provided for @authCheckEmailDelayHint. + /// + /// In en, this message translates to: + /// **'The email can take a few minutes. Also check your spam folder.'** + String get authCheckEmailDelayHint; + + /// No description provided for @authCheckEmailValidity. + /// + /// In en, this message translates to: + /// **'The link is valid for {duration}.'** + String authCheckEmailValidity(String duration); + + /// No description provided for @authCheckEmailValidityOneHour. + /// + /// In en, this message translates to: + /// **'1 hour'** + String get authCheckEmailValidityOneHour; + + /// No description provided for @authCheckEmailResend. + /// + /// In en, this message translates to: + /// **'Resend email'** + String get authCheckEmailResend; + + /// No description provided for @authCheckEmailResent. + /// + /// In en, this message translates to: + /// **'We resent the email, if there is something to send for this address.'** + String get authCheckEmailResent; + + /// No description provided for @authBackToLogin. + /// + /// In en, this message translates to: + /// **'Back to sign in'** + String get authBackToLogin; + + /// No description provided for @authConfirmTitle. + /// + /// In en, this message translates to: + /// **'Confirm email address'** + String get authConfirmTitle; + + /// No description provided for @authConfirmBody. + /// + /// In en, this message translates to: + /// **'One click is enough to confirm your email address.'** + String get authConfirmBody; + + /// No description provided for @authConfirmSubmit. + /// + /// In en, this message translates to: + /// **'Confirm email address'** + String get authConfirmSubmit; + + /// No description provided for @authConfirmSuccess. + /// + /// In en, this message translates to: + /// **'Email confirmed.'** + String get authConfirmSuccess; + + /// No description provided for @authConfirmSuccessMobileWeb. + /// + /// In en, this message translates to: + /// **'You can now also sign in in the app.'** + String get authConfirmSuccessMobileWeb; + + /// No description provided for @authConfirmContinue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get authConfirmContinue; + + /// No description provided for @authRecoveryTitle. + /// + /// In en, this message translates to: + /// **'Set a new password'** + String get authRecoveryTitle; + + /// No description provided for @authRecoveryBody. + /// + /// In en, this message translates to: + /// **'One click is enough to set a new password.'** + String get authRecoveryBody; + + /// No description provided for @authRecoverySubmit. + /// + /// In en, this message translates to: + /// **'Continue to new password'** + String get authRecoverySubmit; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 185ddf6..5699b4b 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -25,4 +25,210 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notFoundBackHome => 'Zurück zum Start'; + + @override + String get authErrorInvalidCredentials => + 'E-Mail-Adresse oder Passwort ist nicht korrekt.'; + + @override + String get authErrorEmailNotConfirmed => + 'Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt.'; + + @override + String get authActionResendConfirmation => + 'Bestätigungs-E-Mail erneut senden'; + + @override + String get authErrorRateLimited => + 'Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.'; + + @override + String get authErrorWeakPasswordLength => 'Das Passwort ist zu kurz.'; + + @override + String get authErrorWeakPasswordCharacters => + 'Das Passwort ist zu einfach. Wähle ein anderes.'; + + @override + String get authErrorWeakPasswordPwned => + 'Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.'; + + @override + String get authErrorSamePassword => + 'Wähle ein anderes als dein bisheriges Passwort.'; + + @override + String get authErrorLinkInvalidOrExpired => + 'Der Link ist abgelaufen oder wurde schon benutzt.'; + + @override + String get authActionRequestNewLink => 'Neuen Link anfordern'; + + @override + String get authErrorEmailInvalid => 'Gib eine gültige E-Mail-Adresse ein.'; + + @override + String get authErrorRegistrationRejected => + 'Registrierung nicht möglich. Bitte prüfe deine Angaben.'; + + @override + String get authErrorNetwork => + 'Keine Verbindung. Für diesen Schritt brauchst du Internet.'; + + @override + String get authErrorUnexpected => + 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; + + @override + String get authActionRetry => 'Erneut versuchen'; + + @override + String authPasswordTooShort(int missing) { + return 'Mindestens 10 Zeichen – es fehlen noch $missing.'; + } + + @override + String get authPasswordCommon => + 'Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.'; + + @override + String get authPasswordRule => + 'Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.'; + + @override + String get authPasswordRequired => 'Gib ein Passwort ein.'; + + @override + String get authDisplayNameEmpty => 'Gib einen Anzeigenamen ein.'; + + @override + String authDisplayNameTooLong(int max) { + return 'Der Anzeigename darf höchstens $max Zeichen lang sein.'; + } + + @override + String get authDisplayNameInvalid => + 'Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen.'; + + @override + String authDisplayNameCounter(int count, int max) { + return '$count/$max'; + } + + @override + String get authAgeNotConfirmed => 'Shuffle ist erst ab 16 Jahren nutzbar.'; + + @override + String get authTermsNotAccepted => + 'Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.'; + + @override + String get authFieldEmailLabel => 'E-Mail-Adresse'; + + @override + String get authFieldPasswordLabel => 'Passwort'; + + @override + String get authFieldDisplayNameLabel => 'Anzeigename'; + + @override + String get authPasswordShow => 'Passwort anzeigen'; + + @override + String get authPasswordHide => 'Passwort verbergen'; + + @override + String get authCheckboxAgeLabel => 'Ich bin mindestens 16 Jahre alt'; + + @override + String get authCheckboxTermsPrefix => 'Ich habe die'; + + @override + String get authCheckboxTermsPrivacyLink => 'Datenschutzerklärung'; + + @override + String get authCheckboxTermsMiddle => 'gelesen und stimme den'; + + @override + String get authCheckboxTermsTermsLink => 'Nutzungsbedingungen'; + + @override + String get authCheckboxTermsSuffix => 'zu'; + + @override + String get authBannerTypeError => 'Fehler'; + + @override + String get authBannerTypeInfo => 'Hinweis'; + + @override + String get authSubmitting => 'Bitte warten …'; + + @override + String get authRegisterTitle => 'Konto erstellen'; + + @override + String get authRegisterSubmit => 'Konto erstellen'; + + @override + String get authRegisterHaveAccount => 'Schon ein Konto? Anmelden'; + + @override + String get authCheckEmailTitle => 'Prüfe dein Postfach'; + + @override + String get authCheckEmailBody => + 'Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.'; + + @override + String get authCheckEmailDelayHint => + 'Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.'; + + @override + String authCheckEmailValidity(String duration) { + return 'Der Link ist $duration gültig.'; + } + + @override + String get authCheckEmailValidityOneHour => '1 Stunde'; + + @override + String get authCheckEmailResend => 'E-Mail erneut senden'; + + @override + String get authCheckEmailResent => + 'Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt.'; + + @override + String get authBackToLogin => 'Zurück zur Anmeldung'; + + @override + String get authConfirmTitle => 'E-Mail-Adresse bestätigen'; + + @override + String get authConfirmBody => + 'Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen.'; + + @override + String get authConfirmSubmit => 'E-Mail-Adresse bestätigen'; + + @override + String get authConfirmSuccess => 'E-Mail bestätigt.'; + + @override + String get authConfirmSuccessMobileWeb => + 'Du kannst dich jetzt auch in der App anmelden.'; + + @override + String get authConfirmContinue => 'Weiter'; + + @override + String get authRecoveryTitle => 'Neues Passwort festlegen'; + + @override + String get authRecoveryBody => + 'Ein Klick genügt, um ein neues Passwort festzulegen.'; + + @override + String get authRecoverySubmit => 'Weiter zum neuen Passwort'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 553cb30..ff39cce 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -24,4 +24,207 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notFoundBackHome => 'Back to start'; + + @override + String get authErrorInvalidCredentials => + 'Email address or password is incorrect.'; + + @override + String get authErrorEmailNotConfirmed => + 'Please confirm your email address first. We sent you an email for this.'; + + @override + String get authActionResendConfirmation => 'Resend confirmation email'; + + @override + String get authErrorRateLimited => + 'Too many attempts. Please wait a few minutes and try again.'; + + @override + String get authErrorWeakPasswordLength => 'The password is too short.'; + + @override + String get authErrorWeakPasswordCharacters => + 'The password is too simple. Choose a different one.'; + + @override + String get authErrorWeakPasswordPwned => + 'This password is very common and easy to guess. Choose a different one.'; + + @override + String get authErrorSamePassword => + 'Choose a password different from your current one.'; + + @override + String get authErrorLinkInvalidOrExpired => + 'The link has expired or has already been used.'; + + @override + String get authActionRequestNewLink => 'Request a new link'; + + @override + String get authErrorEmailInvalid => 'Enter a valid email address.'; + + @override + String get authErrorRegistrationRejected => + 'Registration is not possible. Please check your details.'; + + @override + String get authErrorNetwork => + 'No connection. You need internet access for this step.'; + + @override + String get authErrorUnexpected => 'Something went wrong. Please try again.'; + + @override + String get authActionRetry => 'Try again'; + + @override + String authPasswordTooShort(int missing) { + return 'At least 10 characters – $missing more needed.'; + } + + @override + String get authPasswordCommon => + 'This password is very common and easy to guess. Choose a different one.'; + + @override + String get authPasswordRule => + 'At least 10 characters. Not a very common password.'; + + @override + String get authPasswordRequired => 'Enter a password.'; + + @override + String get authDisplayNameEmpty => 'Enter a display name.'; + + @override + String authDisplayNameTooLong(int max) { + return 'The display name can be at most $max characters long.'; + } + + @override + String get authDisplayNameInvalid => + 'This display name is not allowed. Use visible characters.'; + + @override + String authDisplayNameCounter(int count, int max) { + return '$count/$max'; + } + + @override + String get authAgeNotConfirmed => 'Shuffle can only be used from age 16.'; + + @override + String get authTermsNotAccepted => + 'Please agree to the privacy policy and the terms of use.'; + + @override + String get authFieldEmailLabel => 'Email address'; + + @override + String get authFieldPasswordLabel => 'Password'; + + @override + String get authFieldDisplayNameLabel => 'Display name'; + + @override + String get authPasswordShow => 'Show password'; + + @override + String get authPasswordHide => 'Hide password'; + + @override + String get authCheckboxAgeLabel => 'I am at least 16 years old'; + + @override + String get authCheckboxTermsPrefix => 'I have read the'; + + @override + String get authCheckboxTermsPrivacyLink => 'privacy policy'; + + @override + String get authCheckboxTermsMiddle => 'and agree to the'; + + @override + String get authCheckboxTermsTermsLink => 'terms of use'; + + @override + String get authCheckboxTermsSuffix => ''; + + @override + String get authBannerTypeError => 'Error'; + + @override + String get authBannerTypeInfo => 'Note'; + + @override + String get authSubmitting => 'Please wait …'; + + @override + String get authRegisterTitle => 'Create account'; + + @override + String get authRegisterSubmit => 'Create account'; + + @override + String get authRegisterHaveAccount => 'Already have an account? Sign in'; + + @override + String get authCheckEmailTitle => 'Check your inbox'; + + @override + String get authCheckEmailBody => + 'We sent you an email if the address is not registered yet. Follow the link in it to continue.'; + + @override + String get authCheckEmailDelayHint => + 'The email can take a few minutes. Also check your spam folder.'; + + @override + String authCheckEmailValidity(String duration) { + return 'The link is valid for $duration.'; + } + + @override + String get authCheckEmailValidityOneHour => '1 hour'; + + @override + String get authCheckEmailResend => 'Resend email'; + + @override + String get authCheckEmailResent => + 'We resent the email, if there is something to send for this address.'; + + @override + String get authBackToLogin => 'Back to sign in'; + + @override + String get authConfirmTitle => 'Confirm email address'; + + @override + String get authConfirmBody => + 'One click is enough to confirm your email address.'; + + @override + String get authConfirmSubmit => 'Confirm email address'; + + @override + String get authConfirmSuccess => 'Email confirmed.'; + + @override + String get authConfirmSuccessMobileWeb => + 'You can now also sign in in the app.'; + + @override + String get authConfirmContinue => 'Continue'; + + @override + String get authRecoveryTitle => 'Set a new password'; + + @override + String get authRecoveryBody => 'One click is enough to set a new password.'; + + @override + String get authRecoverySubmit => 'Continue to new password'; } diff --git a/app/pubspec.lock b/app/pubspec.lock index 6276c08..7ffe1ab 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -851,7 +851,7 @@ packages: source: hosted version: "1.4.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c679d81..8bb4152 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: json_annotation: 4.12.0 logging: 1.3.0 intl: 0.20.3 + url_launcher: 6.3.3 dev_dependencies: flutter_test: diff --git a/app/test/core/config/auth_config_test.dart b/app/test/core/config/auth_config_test.dart new file mode 100644 index 0000000..f3eb23e --- /dev/null +++ b/app/test/core/config/auth_config_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/config/auth_config.dart'; + +void main() { + test('KTO-01 Kriterium 8, AUTH-CFG-07: link validity is 3600 s', () { + // The text "1 Stunde / 1 hour" (authCheckEmailValidityOneHour) is only + // correct as long as this holds. + expect(AuthConfig.linkValidity.inSeconds, 3600); + }); +} diff --git a/app/test/core/widgets/app_text_field_test.dart b/app/test/core/widgets/app_text_field_test.dart index 4470e01..ca8435f 100644 --- a/app/test/core/widgets/app_text_field_test.dart +++ b/app/test/core/widgets/app_text_field_test.dart @@ -30,6 +30,20 @@ void main() { expect(find.byIcon(Icons.error_outline), findsOneWidget); }); + testWidgets('counterText replaces the default counter', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Name', + maxLength: 30, + showCounter: true, + counterText: '2/30', + ), + ); + expect(find.text('2/30'), findsOneWidget); + expect(find.text('0/30'), findsNothing); + }); + testWidgets('exposes the label as semantics label of the text field', ( tester, ) async { diff --git a/app/test/features/auth/presentation/auth_router_test.dart b/app/test/features/auth/presentation/auth_router_test.dart index 26c9d16..eb46ecc 100644 --- a/app/test/features/auth/presentation/auth_router_test.dart +++ b/app/test/features/auth/presentation/auth_router_test.dart @@ -115,25 +115,24 @@ void main() { expect(currentPath(router), '/'); }); - testWidgets('placeholder routes of spec 5.3 exist', (tester) async { - final (container: _, :router) = await start(tester); + testWidgets( + 'routes of spec 5.3 exist (01a: real screens, rest placeholders)', + (tester) async { + final (container: _, :router) = await start(tester); - for (final (path, key) in [ - ('/login', 'login'), - ('/register', 'register'), - ('/register/check-email', 'check_email'), - ('/forgot-password', 'forgot_password'), - ('/auth/confirm?token_hash=x&type=email', 'auth_confirm'), - ]) { - router.go(path); - await tester.pumpAndSettle(); - expect( - find.byKey(Key('auth_placeholder_$key')), - findsOneWidget, - reason: path, - ); - } - }); + for (final (path, key) in [ + ('/login', 'auth_placeholder_login'), + ('/register', 'register_screen'), + ('/register/check-email', 'check_email_screen'), + ('/forgot-password', 'auth_placeholder_forgot_password'), + ('/auth/confirm?token_hash=x&type=email', 'email_link_screen'), + ]) { + router.go(path); + await tester.pumpAndSettle(); + expect(find.byKey(Key(key)), findsOneWidget, reason: path); + } + }, + ); }); group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () { diff --git a/app/test/features/auth/presentation/auth_ui_support.dart b/app/test/features/auth/presentation/auth_ui_support.dart new file mode 100644 index 0000000..91af0a6 --- /dev/null +++ b/app/test/features/auth/presentation/auth_ui_support.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_riverpod/misc.dart' show ProviderListenable; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/l10n/app_locale.dart'; +import 'package:shuffle/core/platform/platform_info.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_repository.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/presentation/auth_routes.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +import '../../../helpers/fakes.dart'; +import '../../../helpers/pump_app.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +class FakeRegistrationInput extends Fake implements RegistrationInput {} + +/// [AppLocale] with a fixed language (tests do not depend on the device). +class FixedAppLocale extends AppLocale { + FixedAppLocale(this.locale); + + final Locale locale; + + @override + Locale build() => locale; +} + +const commonTestPasswords = SetCommonPasswordList({'passwort1234'}); + +void registerAuthFallbacks() { + registerFallbackValue(FakeRegistrationInput()); + registerFallbackValue(EmailLinkType.confirmation); +} + +Result ok() => const Result.success(null); + +Result failed(AuthFailure value) => Result.failure(value); + +/// Overrides for auth UI tests: repository mock, fixed language, common +/// password list, URL opener and platform. +List authUiOverrides( + MockAuthRepository repository, { + Locale locale = const Locale('de'), + UrlOpener? urlOpener, + bool mobileWeb = false, + Future Function()? commonPasswords, + List extra = const [], +}) => testOverrides( + overrides: [ + authRepositoryProvider.overrideWithValue(repository), + appLocaleProvider.overrideWith(() => FixedAppLocale(locale)), + commonPasswordListProvider.overrideWith( + (ref) => commonPasswords?.call() ?? Future.value(commonTestPasswords), + ), + urlOpenerProvider.overrideWithValue(urlOpener ?? (_) async => true), + isMobileWebProvider.overrideWithValue(mobileWeb), + ...extra, + ], +); + +/// The text field inside an `AppTextField`/`AppPasswordField` with [key]. +Finder fieldOf(String key) => + find.descendant(of: find.byKey(Key(key)), matching: find.byType(TextField)); + +/// Keeps an auto-dispose provider alive during a controller test. +void keepAlive(ProviderContainer container, ProviderListenable p) { + container.listen(p, (_, _) {}); +} + +typedef AuthUiHandle = ({ProviderContainer container, GoRouter router}); + +/// Pumps the real auth routes (register, check-email, confirm; the others are +/// placeholders) at [location] on a phone-sized screen. +Future pumpAuthRoute( + WidgetTester tester, + MockAuthRepository repository, { + String location = '/register', + Object? extra, + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + UrlOpener? urlOpener, + bool mobileWeb = false, + bool settle = true, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = GoRouter( + initialLocation: location, + initialExtra: extra, + routes: authRoutes, + ); + addTearDown(router.dispose); + final container = await pumpApp( + tester, + router: router, + locale: locale, + themeMode: themeMode, + textScale: textScale, + settle: settle, + overrides: authUiOverrides( + repository, + locale: locale, + urlOpener: urlOpener, + mobileWeb: mobileWeb, + ), + ); + return (container: container, router: router); +} + +/// All texts currently visible (for comparing two screens exactly). +List visibleTexts(WidgetTester tester) => [ + for (final text in tester.widgetList(find.byType(Text))) + if (text.data != null) text.data!, +]; diff --git a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart new file mode 100644 index 0000000..820ce0f --- /dev/null +++ b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + Object? extra = 'anna@example.com', + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + }) => pumpAuthRoute( + tester, + repository, + location: '/register/check-email', + extra: extra, + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + ); + + Future tapResend(WidgetTester tester) async { + await tester.ensureVisible(find.byKey(const Key('check_email_resend'))); + await tester.tap(find.byKey(const Key('check_email_resend'))); + await tester.pumpAndSettle(); + } + + group('KTO-01 Kriterium 1, 4: neutral message', () { + testWidgets('shows body, validity and the delay hint always', ( + tester, + ) async { + await open(tester); + + expect(find.text(_de.authCheckEmailTitle), findsOneWidget); + expect(find.text(_de.authCheckEmailBody), findsOneWidget); + expect(find.text(_de.authCheckEmailValidity('1 Stunde')), findsOneWidget); + expect(find.text(_de.authCheckEmailDelayHint), findsOneWidget); + expect(find.byKey(const Key('check_email_email')), findsNothing); + }); + + testWidgets('the text does not contain the address', (tester) async { + await open(tester); + expect(find.textContaining('anna@example.com'), findsNothing); + }); + }); + + group('KTO-01 Kriterium 6: resend', () { + testWidgets('sends to the known address and confirms', (tester) async { + await open(tester); + + await tapResend(tester); + + verify(() => repository.resendConfirmation('anna@example.com')).called(1); + expect(find.text(_de.authCheckEmailResent), findsOneWidget); + }); + + testWidgets('F12: emailSendLimited gives exactly the same feedback', ( + tester, + ) async { + await open(tester); + await tapResend(tester); + final success = visibleTexts(tester); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + when( + () => repository.resendConfirmation(any()), + ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); + await open(tester); + await tapResend(tester); + final limited = visibleTexts(tester); + + expect(limited, success); + expect(limited, isNot(contains(_de.authErrorRateLimited))); + expect(limited, contains(_de.authCheckEmailResent)); + }); + + testWidgets('IP rate limit shows the rate-limit hint', (tester) async { + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => failed(const AuthFailure.rateLimited())); + await open(tester); + + await tapResend(tester); + + expect(find.text(_de.authErrorRateLimited), findsOneWidget); + expect(find.text(_de.authCheckEmailResent), findsNothing); + }); + + testWidgets('offline shows the offline hint', (tester) async { + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester); + + await tapResend(tester); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + }); + + testWidgets('the button is locked while sending (no double tap)', ( + tester, + ) async { + final completer = Completer>(); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) => completer.future); + await open(tester); + + await tester.tap(find.byKey(const Key('check_email_resend'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('check_email_resend'))); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + verify(() => repository.resendConfirmation(any())).called(1); + completer.complete(ok()); + await tester.pumpAndSettle(); + }); + }); + + group('KTO-01 Kriterium 6: address missing after a web reload', () { + testWidgets('asks for the address and validates it', (tester) async { + await open(tester, extra: null); + + expect(find.byKey(const Key('check_email_email')), findsOneWidget); + await tapResend(tester); + expect(find.text(_de.authErrorEmailInvalid), findsOneWidget); + verifyNever(() => repository.resendConfirmation(any())); + + await tester.enterText( + fieldOf('check_email_email'), + ' berta@example.com ', + ); + await tester.pump(); + expect(find.text(_de.authErrorEmailInvalid), findsNothing); + await tapResend(tester); + + verify(() => repository.resendConfirmation('berta@example.com')) + .called(1); + expect(find.text(_de.authCheckEmailResent), findsOneWidget); + }); + + testWidgets('an extra of the wrong type counts as missing', (tester) async { + await open(tester, extra: 42); + expect(find.byKey(const Key('check_email_email')), findsOneWidget); + }); + }); + + testWidgets('back to sign in leads to /login', (tester) async { + final (:container, :router) = await open(tester); + + await tester.ensureVisible( + find.byKey(const Key('check_email_back_to_login')), + ); + await tester.tap(find.byKey(const Key('check_email_back_to_login'))); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/login'); + }); + + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authCheckEmailBody), findsOneWidget); + expect(find.text(_en.authCheckEmailValidity('1 hour')), findsOneWidget); + expect(find.text(_en.authCheckEmailDelayHint), findsOneWidget); + expect(find.text(_en.authCheckEmailResend), findsOneWidget); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + for (final withEmail in [true, false]) { + testWidgets('meets the guidelines ($mode, address known: $withEmail)', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open( + tester, + themeMode: mode, + extra: withEmail ? 'anna@example.com' : null, + ); + await tapResend(tester); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + } + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await open(tester, extra: null, textScale: 2, size: const Size(320, 640)); + await tapResend(tester); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/features/auth/presentation/check_email/resend_controller_test.dart b/app/test/features/auth/presentation/check_email/resend_controller_test.dart new file mode 100644 index 0000000..d9d0325 --- /dev/null +++ b/app/test/features/auth/presentation/check_email/resend_controller_test.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; + +import '../auth_ui_support.dart'; + +void main() { + late MockAuthRepository repository; + late ProviderContainer container; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + container = ProviderContainer.test(overrides: authUiOverrides(repository)); + keepAlive(container, resendControllerProvider); + }); + + ResendController notifier() => + container.read(resendControllerProvider.notifier); + ResendState state() => container.read(resendControllerProvider); + + void stub(Result result) => + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => result); + + group('KTO-01 Kriterium 6: resend', () { + test('sends to the trimmed address and reports success', () async { + stub(ok()); + + await notifier().resend(' anna@example.com ', origin: ResendOrigin.login); + + verify(() => repository.resendConfirmation('anna@example.com')).called(1); + expect(state().sent, isTrue); + expect(state().failure, isNull); + }); + + test('an invalid address is not sent', () async { + await notifier().resend('anna', origin: ResendOrigin.checkEmail); + + verifyNever(() => repository.resendConfirmation(any())); + expect(state().emailError, EmailError.invalid); + notifier().clearEmailError(); + expect(state().emailError, isNull); + }); + + test('the button is locked while a request runs', () async { + final completer = Completer>(); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) => completer.future); + + final first = notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + await Future.delayed(Duration.zero); + expect(state().isSending, isTrue); + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + completer.complete(ok()); + await first; + + verify(() => repository.resendConfirmation(any())).called(1); + expect(state().sent, isTrue); + }); + }); + + group('KTO-01 Kriterium 4, 6 (F12): emailSendLimited', () { + test('from the check-email screen counts as sent', () async { + stub(failed(const AuthFailure.emailSendLimited())); + + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + + expect(state().sent, isTrue); + expect(state().failure, isNull); + }); + + test('from the login screen becomes the rate-limit failure', () async { + stub(failed(const AuthFailure.emailSendLimited())); + + await notifier().resend('anna@example.com', origin: ResendOrigin.login); + + expect(state().sent, isFalse); + expect(state().failure, const AuthFailure.rateLimited()); + }); + + test('over_request_rate_limit is shown from both origins', () async { + stub(failed(const AuthFailure.rateLimited())); + + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + + expect(state().failure, const AuthFailure.rateLimited()); + }); + + test('Zustände Kriterium 2: network failure is reported', () async { + stub(failed(const AuthFailure.network())); + + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + + expect(state().failure, const AuthFailure.network()); + expect(state().isSending, isFalse); + }); + }); +} diff --git a/app/test/features/auth/presentation/email_link/email_link_controller_test.dart b/app/test/features/auth/presentation/email_link/email_link_controller_test.dart new file mode 100644 index 0000000..0a03c95 --- /dev/null +++ b/app/test/features/auth/presentation/email_link/email_link_controller_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/email_link_request.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/presentation/email_link/email_link_controller.dart'; + +import '../auth_ui_support.dart'; + +void main() { + late MockAuthRepository repository; + late ProviderContainer container; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + container = ProviderContainer.test(overrides: authUiOverrides(repository)); + }); + + EmailLinkController start(String? token, String? type) { + final provider = emailLinkControllerProvider(token, type); + keepAlive(container, provider); + return container.read(provider.notifier); + } + + EmailLinkState stateOf(String? token, String? type) => + container.read(emailLinkControllerProvider(token, type)); + + group('KTO-01 Kriterium 8: parameters', () { + test('email and signup mean confirmation, recovery stays recovery', () { + expect( + EmailLinkRequest.tryParse('t', 'email')?.type, + EmailLinkType.confirmation, + ); + expect( + EmailLinkRequest.tryParse('t', 'signup')?.type, + EmailLinkType.confirmation, + ); + expect( + EmailLinkRequest.tryParse('t', 'recovery')?.type, + EmailLinkType.recovery, + ); + }); + + test('missing token or unknown type give no request', () { + expect(EmailLinkRequest.tryParse(null, 'email'), isNull); + expect(EmailLinkRequest.tryParse(' ', 'email'), isNull); + expect(EmailLinkRequest.tryParse('t', null), isNull); + expect(EmailLinkRequest.tryParse('t', 'magiclink'), isNull); + }); + + test( + 'invalid parameters start in the invalid state and never verify', + () async { + final controller = start(null, 'email'); + expect(stateOf(null, 'email').phase, EmailLinkPhase.invalid); + + await controller.confirm(); + + verifyNever(() => repository.verifyEmailLink(any(), any())); + }, + ); + }); + + group('KTO-01 Kriterium 1, 8: redeeming', () { + test('creating the controller does not redeem the link (E3)', () { + start('hash', 'email'); + + verifyNever(() => repository.verifyEmailLink(any(), any())); + expect(stateOf('hash', 'email').phase, EmailLinkPhase.ready); + }); + + test('confirm redeems once with token and type', () async { + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + final controller = start('hash', 'signup'); + + await controller.confirm(); + await controller.confirm(); + + verify( + () => repository.verifyEmailLink('hash', EmailLinkType.confirmation), + ).called(1); + expect(stateOf('hash', 'signup').phase, EmailLinkPhase.success); + }); + + test('an expired or used link ends in the invalid state', () async { + when(() => repository.verifyEmailLink(any(), any())).thenAnswer( + (_) async => failed(const AuthFailure.linkInvalidOrExpired()), + ); + final controller = start('hash', 'email'); + + await controller.confirm(); + + expect(stateOf('hash', 'email').phase, EmailLinkPhase.invalid); + }); + + test( + 'Zustände Kriterium 2: a network failure allows another try', + () async { + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + final controller = start('hash', 'email'); + + await controller.confirm(); + + final state = stateOf('hash', 'email'); + expect(state.phase, EmailLinkPhase.ready); + expect(state.failure, const AuthFailure.network()); + + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + await controller.confirm(); + expect(stateOf('hash', 'email').phase, EmailLinkPhase.success); + expect(stateOf('hash', 'email').failure, isNull); + }, + ); + }); +} diff --git a/app/test/features/auth/presentation/email_link/email_link_screen_test.dart b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart new file mode 100644 index 0000000..7f4e627 --- /dev/null +++ b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + String location = '/auth/confirm?token_hash=abc123&type=email', + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + bool mobileWeb = false, + }) => pumpAuthRoute( + tester, + repository, + location: location, + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + mobileWeb: mobileWeb, + ); + + Future tapSubmit(WidgetTester tester) async { + await tester.ensureVisible(find.byKey(const Key('email_link_submit'))); + await tester.tap(find.byKey(const Key('email_link_submit'))); + await tester.pumpAndSettle(); + } + + group('KTO-01 Kriterium 1, 8: nothing happens before the click (E3)', () { + testWidgets('opening the page does not redeem the link', (tester) async { + await open(tester); + + // Title and button share the text "E-Mail-Adresse bestätigen". + expect(find.text(_de.authConfirmTitle), findsNWidgets(2)); + expect(find.text(_de.authConfirmBody), findsOneWidget); + verifyNever(() => repository.verifyEmailLink(any(), any())); + }); + + testWidgets('type=signup is accepted as confirmation', (tester) async { + await open( + tester, + location: '/auth/confirm?token_hash=abc123&type=signup', + ); + + await tapSubmit(tester); + + verify( + () => repository.verifyEmailLink('abc123', EmailLinkType.confirmation), + ).called(1); + }); + }); + + group('KTO-01 Kriterium 1: confirmation', () { + testWidgets('the click redeems the link and shows the success', ( + tester, + ) async { + await open(tester); + + await tapSubmit(tester); + + verify( + () => repository.verifyEmailLink('abc123', EmailLinkType.confirmation), + ).called(1); + expect(find.text(_de.authConfirmSuccess), findsOneWidget); + expect(find.text(_de.authConfirmSuccessMobileWeb), findsNothing); + expect(find.byKey(const Key('email_link_submit')), findsNothing); + }); + + testWidgets('mobile web adds the hint about the app', (tester) async { + await open(tester, mobileWeb: true); + + await tapSubmit(tester); + + expect( + find.text( + '${_de.authConfirmSuccess} ${_de.authConfirmSuccessMobileWeb}', + ), + findsOneWidget, + ); + }); + + testWidgets('continue leads to the start page', (tester) async { + final (:container, :router) = await open(tester); + await tapSubmit(tester); + + await tester.tap(find.byKey(const Key('email_link_continue'))); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/'); + }); + + testWidgets('while redeeming the button is locked', (tester) async { + final completer = Completer>(); + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) => completer.future); + await open(tester); + + await tester.tap(find.byKey(const Key('email_link_submit'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('email_link_submit'))); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + verify(() => repository.verifyEmailLink(any(), any())).called(1); + completer.complete(ok()); + await tester.pumpAndSettle(); + }); + }); + + group('KTO-01 Kriterium 8: invalid links', () { + testWidgets('an expired or used link shows the error and a way out', ( + tester, + ) async { + when(() => repository.verifyEmailLink(any(), any())).thenAnswer( + (_) async => failed(const AuthFailure.linkInvalidOrExpired()), + ); + final (:container, :router) = await open(tester); + + await tapSubmit(tester); + + expect(find.text(_de.authErrorLinkInvalidOrExpired), findsOneWidget); + expect(find.byKey(const Key('email_link_submit')), findsNothing); + + await tester.tap(find.text(_de.authActionRequestNewLink)); + await tester.pumpAndSettle(); + expect(currentPath(router), '/login'); + }); + + for (final location in [ + '/auth/confirm', + '/auth/confirm?type=email', + '/auth/confirm?token_hash=abc', + '/auth/confirm?token_hash=abc&type=magiclink', + ]) { + testWidgets('missing or unknown parameters: $location', (tester) async { + await open(tester, location: location); + + expect(find.text(_de.authErrorLinkInvalidOrExpired), findsOneWidget); + expect(find.byKey(const Key('email_link_submit')), findsNothing); + verifyNever(() => repository.verifyEmailLink(any(), any())); + }); + } + + testWidgets('Zustände Kriterium 2: offline allows another try', ( + tester, + ) async { + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester); + + await tapSubmit(tester); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + expect(find.byKey(const Key('email_link_submit')), findsOneWidget); + + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + await tapSubmit(tester); + expect(find.text(_de.authConfirmSuccess), findsOneWidget); + }); + }); + + testWidgets('recovery links show their own texts and redeem as recovery', ( + tester, + ) async { + await open( + tester, + location: '/auth/confirm?token_hash=abc123&type=recovery', + ); + + expect(find.text(_de.authRecoveryTitle), findsOneWidget); + expect(find.text(_de.authRecoverySubmit), findsOneWidget); + await tapSubmit(tester); + verify(() => repository.verifyEmailLink('abc123', EmailLinkType.recovery)) + .called(1); + }); + + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authConfirmTitle), findsNWidgets(2)); + expect(find.text(_en.authConfirmBody), findsOneWidget); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, themeMode: mode); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + } + + testWidgets('the error state meets the guidelines', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, location: '/auth/confirm?token_hash=x&type=bad'); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await open(tester, textScale: 2, size: const Size(320, 640)); + await tapSubmit(tester); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/features/auth/presentation/register/register_controller_test.dart b/app/test/features/auth/presentation/register/register_controller_test.dart new file mode 100644 index 0000000..25b5b14 --- /dev/null +++ b/app/test/features/auth/presentation/register/register_controller_test.dart @@ -0,0 +1,408 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/register/register_controller.dart'; +import 'package:shuffle/features/auth/presentation/register/register_state.dart'; + +import '../../../../helpers/fakes.dart'; +import '../auth_ui_support.dart'; + +void main() { + late MockAuthRepository repository; + late List opened; + late bool openResult; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + opened = []; + openResult = true; + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + }); + + ProviderContainer makeContainer({ + Locale locale = const Locale('de'), + Future Function()? commonPasswords, + }) { + final container = ProviderContainer.test( + overrides: authUiOverrides( + repository, + locale: locale, + urlOpener: (uri) async { + opened.add(uri); + return openResult; + }, + commonPasswords: commonPasswords, + ), + ); + keepAlive(container, registerControllerProvider); + return container; + } + + RegisterController notifier(ProviderContainer c) => + c.read(registerControllerProvider.notifier); + + RegisterState stateOf(ProviderContainer c) => + c.read(registerControllerProvider); + + void fillValid( + RegisterController controller, { + String email = ' anna@example.com ', + String password = 'geheim-und-lang', + String name = ' Anna ', + }) { + controller + ..emailChanged(email) + ..passwordChanged(password) + ..displayNameChanged(name) + ..ageConfirmedChanged(value: true) + ..termsAcceptedChanged(value: true); + } + + group('KTO-01 Kriterium 1: submit', () { + test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async { + final container = makeContainer(locale: const Locale('en')); + fillValid(notifier(container)); + + final result = await notifier(container).submit(); + + expect(result, isTrue); + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.email, 'anna@example.com'); + expect(input.password, 'geheim-und-lang'); + expect(input.displayName, 'Anna'); + expect(input.ageConfirmed, isTrue); + expect(input.termsAccepted, isTrue); + expect(input.termsVersion, testAppConfig().termsVersion); + expect(input.locale, 'en'); + expect(stateOf(container).isSubmitting, isFalse); + }); + + test('KTO-01 Kriterium 11 (Client): mail language follows the active UI language', () async { + final container = makeContainer(); + fillValid(notifier(container)); + + await notifier(container).submit(); + + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.locale, 'de'); + }); + + test('ignores a second submit while one is running', () async { + final completer = Completer>(); + when(() => repository.signUp(any())).thenAnswer((_) => completer.future); + final container = makeContainer(); + fillValid(notifier(container)); + + final first = notifier(container).submit(); + await Future.delayed(Duration.zero); + expect(stateOf(container).isSubmitting, isTrue); + expect(await notifier(container).submit(), isFalse); + completer.complete(ok()); + expect(await first, isTrue); + verify(() => repository.signUp(any())).called(1); + }); + }); + + group('KTO-01 Kriterium 2: minimum age', () { + test( + 'without the age confirmation nothing is sent and the error shows', + () async { + final container = makeContainer(); + fillValid(notifier(container)); + notifier(container).ageConfirmedChanged(value: false); + + expect(await notifier(container).submit(), isFalse); + + verifyNever(() => repository.signUp(any())); + expect(stateOf(container).shownAgeError, isTrue); + expect(stateOf(container).focusField, RegisterField.ageConfirmation); + }, + ); + + test('QUE-04 Kriterium 1: without consent nothing is sent', () async { + final container = makeContainer(); + fillValid(notifier(container)); + notifier(container).termsAcceptedChanged(value: false); + + expect(await notifier(container).submit(), isFalse); + + verifyNever(() => repository.signUp(any())); + expect(stateOf(container).shownTermsError, isTrue); + }); + + test('the error disappears when the box is ticked', () async { + final container = makeContainer(); + await notifier(container).submit(); + expect(stateOf(container).shownAgeError, isTrue); + + notifier(container).ageConfirmedChanged(value: true); + + expect(stateOf(container).shownAgeError, isFalse); + }); + }); + + group('KTO-01 Kriterium 3: password', () { + test('is checked while typing: too short names the missing count', () { + final container = makeContainer(); + + notifier(container).passwordChanged('123456789'); + + expect( + stateOf(container).shownPasswordError, + const PasswordError.tooShort(1), + ); + }); + + test('10 characters are enough', () { + final container = makeContainer(); + notifier(container).passwordChanged('1234567890'); + expect(stateOf(container).shownPasswordError, isNull); + }); + + test('a common password is rejected once the list is loaded', () async { + final container = makeContainer(); + + notifier(container).passwordChanged(' PassWort1234 '); + await notifier(container).loadCommonPasswords(); + + expect(stateOf(container).shownPasswordError, isA()); + expect(await notifier(container).submit(), isFalse); + verifyNever(() => repository.signUp(any())); + }); + + test('an empty password is shown as required after blur', () { + final container = makeContainer(); + notifier(container).fieldBlurred(RegisterField.password); + expect(stateOf(container).shownPasswordRequired, isTrue); + }); + + test( + 'weak_password from the server is mapped to the password field', + () async { + when(() => repository.signUp(any())).thenAnswer( + (_) async => failed( + const AuthFailure.weakPassword([WeakPasswordReason.length]), + ), + ); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isFalse); + + expect(stateOf(container).serverPasswordReasons, [ + WeakPasswordReason.length, + ]); + expect(stateOf(container).bannerFailure, isNull); + expect(stateOf(container).focusField, RegisterField.password); + + notifier(container).passwordChanged('anderes-passwort'); + expect(stateOf(container).serverPasswordReasons, isNull); + }, + ); + + test('Zustände Kriterium 3: if the list cannot be loaded, only the length rule runs', () async { + final container = makeContainer( + commonPasswords: () => Future.error(StateError('asset missing')), + ); + fillValid(notifier(container), password: 'passwort1234'); + + expect(await notifier(container).submit(), isTrue); + }); + }); + + group('KTO-01 Kriterium 4: no hint whether the account exists', () { + test('emailSendLimited counts as success (F12)', () async { + when( + () => repository.signUp(any()), + ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isTrue); + expect(stateOf(container).bannerFailure, isNull); + }); + + test('plain success leads to the same result', () async { + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isTrue); + expect(stateOf(container).bannerFailure, isNull); + }); + + test('over_request_rate_limit stays a visible rate-limit banner', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.rateLimited())); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isFalse); + expect(stateOf(container).bannerFailure, const AuthFailure.rateLimited()); + }); + }); + + group('KTO-01 Kriterium 10: display name', () { + Future errorFor(String name) async { + final container = makeContainer(); + notifier(container) + ..displayNameChanged(name) + ..fieldBlurred(RegisterField.displayName); + return stateOf(container).shownDisplayNameError; + } + + test('empty and whitespace only are rejected', () async { + expect(await errorFor(''), DisplayNameError.empty); + expect(await errorFor(' '), DisplayNameError.empty); + }); + + test('30 code points are valid, 31 are too long', () async { + expect(await errorFor('a' * 30), isNull); + expect(await errorFor('a' * 31), DisplayNameError.tooLong); + }); + + test('counts code points, not UTF-16 units', () async { + expect(await errorFor('😀' * 30), isNull); + expect(await errorFor('😀' * 31), DisplayNameError.tooLong); + }); + + test('the counter uses the trimmed code point length', () { + final container = makeContainer(); + notifier(container).displayNameChanged(' 😀😀 '); + expect(stateOf(container).displayNameLength, 2); + }); + + test('U+200B anywhere is invalid', () async { + expect(await errorFor('A​B'), DisplayNameError.invalidCharacter); + expect(await errorFor('​'), DisplayNameError.invalidCharacter); + }); + + test('an invalid name blocks the submit', () async { + final container = makeContainer(); + fillValid(notifier(container), name: ' '); + + expect(await notifier(container).submit(), isFalse); + + verifyNever(() => repository.signUp(any())); + expect(stateOf(container).focusField, RegisterField.displayName); + }); + }); + + group('KTO-01 Kriterium 1: email', () { + test( + 'an invalid address is shown after blur and blocks the submit', + () async { + final container = makeContainer(); + notifier(container) + ..emailChanged('anna') + ..fieldBlurred(RegisterField.email); + + expect(stateOf(container).shownEmailError, EmailError.invalid); + expect(await notifier(container).submit(), isFalse); + }, + ); + + test('invalid email from the server goes to the email field', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.invalidEmail())); + final container = makeContainer(); + fillValid(notifier(container)); + + await notifier(container).submit(); + + expect(stateOf(container).serverEmailInvalid, isTrue); + expect(stateOf(container).focusField, RegisterField.email); + }); + }); + + group('Zustände Kriterium 2, 3: failures keep the input', () { + for (final failure in [ + const AuthFailure.network(), + const AuthFailure.unexpected(), + const AuthFailure.registrationRejected(), + ]) { + test('${failure.runtimeType} becomes a banner', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(failure)); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isFalse); + + final state = stateOf(container); + expect(state.bannerFailure, failure); + expect(state.isSubmitting, isFalse); + expect(state.password, 'geheim-und-lang'); + expect(state.displayName, ' Anna '); + expect(state.ageConfirmed, isTrue); + }); + } + + test('a new attempt clears the banner', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + final container = makeContainer(); + fillValid(notifier(container)); + await notifier(container).submit(); + + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + expect(await notifier(container).submit(), isTrue); + expect(stateOf(container).bannerFailure, isNull); + }); + }); + + group('QUE-04 Kriterium 1: legal texts', () { + test('open the URLs from the app config', () async { + final container = makeContainer(); + + expect( + await notifier(container).openLegalDocument(LegalDocument.privacy), + isTrue, + ); + await notifier(container).openLegalDocument(LegalDocument.terms); + + expect(opened, [ + Uri.parse(testAppConfig().legalPrivacyUrl), + Uri.parse(testAppConfig().legalTermsUrl), + ]); + }); + + test('report a failure to open', () async { + openResult = false; + final container = makeContainer(); + expect( + await notifier(container).openLegalDocument(LegalDocument.terms), + isFalse, + ); + }); + }); + + test('factory provider reads terms version and locale', () { + final container = makeContainer(); + final input = container.read(registrationInputFactoryProvider)( + email: 'a@b.de', + password: 'x', + displayName: 'n', + ageConfirmed: true, + termsAccepted: true, + ); + expect(input.termsVersion, 'test-1'); + expect(input.locale, 'de'); + }); +} diff --git a/app/test/features/auth/presentation/register/register_screen_test.dart b/app/test/features/auth/presentation/register/register_screen_test.dart new file mode 100644 index 0000000..85c4504 --- /dev/null +++ b/app/test/features/auth/presentation/register/register_screen_test.dart @@ -0,0 +1,480 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/fakes.dart'; +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + UrlOpener? urlOpener, + }) => pumpAuthRoute( + tester, + repository, + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + urlOpener: urlOpener, + ); + + Future type(WidgetTester tester, String key, String text) async { + await tester.ensureVisible(fieldOf(key)); + await tester.enterText(fieldOf(key), text); + await tester.pump(); + } + + Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.tap(finder); + await tester.pump(); + } + + Future fillValid(WidgetTester tester) async { + await type(tester, 'register_email', 'anna@example.com'); + await type(tester, 'register_password', 'geheim-und-lang'); + await type(tester, 'register_display_name', 'Anna'); + await tapKey(tester, 'register_age_checkbox'); + await tapKey(tester, 'register_terms_checkbox'); + } + + Future submit(WidgetTester tester) async { + await tapKey(tester, 'register_submit'); + await tester.pumpAndSettle(); + } + + group('KTO-01 Kriterium 1: valid registration', () { + testWidgets('sends the data and shows the check-email page', ( + tester, + ) async { + final (:container, :router) = await open(tester); + + await fillValid(tester); + await submit(tester); + + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.email, 'anna@example.com'); + expect(input.displayName, 'Anna'); + expect(input.locale, 'de'); + expect(input.termsVersion, testAppConfig().termsVersion); + expect(currentPath(router), '/register/check-email'); + expect(find.byKey(const Key('check_email_screen')), findsOneWidget); + expect(find.text(_de.authCheckEmailBody), findsOneWidget); + }); + + testWidgets('the enter key in the last field submits', (tester) async { + final (:container, :router) = await open(tester); + await fillValid(tester); + + await tester.showKeyboard(fieldOf('register_display_name')); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + verify(() => repository.signUp(any())).called(1); + expect(currentPath(router), '/register/check-email'); + }); + + testWidgets('the fields carry autofill hints and keyboard types', ( + tester, + ) async { + await open(tester); + + final email = tester.widget(fieldOf('register_email')); + expect(email.autofillHints, contains(AutofillHints.email)); + expect(email.keyboardType, TextInputType.emailAddress); + expect(email.textInputAction, TextInputAction.next); + final password = tester.widget(fieldOf('register_password')); + expect(password.autofillHints, contains(AutofillHints.newPassword)); + expect(password.obscureText, isTrue); + final name = tester.widget(fieldOf('register_display_name')); + expect(name.textInputAction, TextInputAction.done); + }); + }); + + group('KTO-01 Kriterium 2: minimum age', () { + testWidgets( + 'without the confirmation the reason shows and nothing is sent', + (tester) async { + await open(tester); + await type(tester, 'register_email', 'anna@example.com'); + await type(tester, 'register_password', 'geheim-und-lang'); + await type(tester, 'register_display_name', 'Anna'); + await tapKey(tester, 'register_terms_checkbox'); + + await submit(tester); + + expect(find.text(_de.authAgeNotConfirmed), findsOneWidget); + verifyNever(() => repository.signUp(any())); + }, + ); + + testWidgets('QUE-04 Kriterium 1: without consent the error shows', ( + tester, + ) async { + await open(tester); + await fillValid(tester); + await tapKey(tester, 'register_terms_checkbox'); + + await submit(tester); + + expect(find.text(_de.authTermsNotAccepted), findsOneWidget); + verifyNever(() => repository.signUp(any())); + }); + }); + + group('KTO-01 Kriterium 3: password at the field', () { + testWidgets('shows the missing characters while typing', (tester) async { + await open(tester); + + await type(tester, 'register_password', 'abc'); + + expect(find.text(_de.authPasswordTooShort(7)), findsOneWidget); + await type(tester, 'register_password', 'abcdefghi'); + expect(find.text(_de.authPasswordTooShort(1)), findsOneWidget); + await type(tester, 'register_password', 'abcdefghij'); + expect(find.textContaining('Mindestens 10 Zeichen –'), findsNothing); + }); + + testWidgets('names a common password', (tester) async { + await open(tester); + + await type(tester, 'register_password', 'Passwort1234'); + await tester.pumpAndSettle(); + + expect(find.text(_de.authPasswordCommon), findsOneWidget); + }); + + testWidgets('shows the rule as hint before any error', (tester) async { + await open(tester); + expect(find.text(_de.authPasswordRule), findsOneWidget); + }); + + testWidgets('a weak_password answer shows at the field', (tester) async { + when(() => repository.signUp(any())).thenAnswer( + (_) async => + failed(const AuthFailure.weakPassword([WeakPasswordReason.length])), + ); + await open(tester); + await fillValid(tester); + + await submit(tester); + + expect(find.text(_de.authErrorWeakPasswordLength), findsOneWidget); + expect(find.byKey(const Key('register_banner')), findsNothing); + }); + }); + + group('KTO-01 Kriterium 10: display name at the field', () { + testWidgets('empty name shows the error on submit', (tester) async { + await open(tester); + await type(tester, 'register_email', 'anna@example.com'); + await type(tester, 'register_password', 'geheim-und-lang'); + + await submit(tester); + + expect(find.text(_de.authDisplayNameEmpty), findsOneWidget); + verifyNever(() => repository.signUp(any())); + }); + + testWidgets('31 characters are too long, the counter follows', ( + tester, + ) async { + await open(tester); + + await type(tester, 'register_display_name', 'a' * 31); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(find.text(_de.authDisplayNameTooLong(30)), findsOneWidget); + expect(find.text('31/30'), findsOneWidget); + }); + + testWidgets('the counter counts trimmed code points', (tester) async { + await open(tester); + + await type(tester, 'register_display_name', ' 😀😀 '); + + expect(find.text('2/30'), findsOneWidget); + }); + + testWidgets('U+200B shows the same message as an invalid name', ( + tester, + ) async { + await open(tester); + + await type(tester, 'register_display_name', 'A​B'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(find.text(_de.authDisplayNameInvalid), findsOneWidget); + }); + }); + + group('KTO-01 Kriterium 1: email at the field', () { + testWidgets('an invalid address shows after leaving the field', ( + tester, + ) async { + await open(tester); + + await type(tester, 'register_email', 'anna'); + await tester.tap(fieldOf('register_password')); + await tester.pumpAndSettle(); + + expect(find.text(_de.authErrorEmailInvalid), findsOneWidget); + }); + + testWidgets('after a failed submit the first invalid field has focus', ( + tester, + ) async { + await open(tester); + + await submit(tester); + + final email = tester.widget(fieldOf('register_email')); + expect(email.focusNode?.hasFocus, isTrue); + }); + }); + + group('KTO-01 Kriterium 4 (F12): same screen for every answer', () { + Future> textsAfterSubmit( + WidgetTester tester, + Result answer, + ) async { + when(() => repository.signUp(any())).thenAnswer((_) async => answer); + final (:container, :router) = await open(tester); + await fillValid(tester); + await submit(tester); + expect(currentPath(router), '/register/check-email'); + final texts = visibleTexts(tester); + // Unmount so that provider disposal timers run before the next pump. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + return texts; + } + + testWidgets('success and emailSendLimited show identical texts', ( + tester, + ) async { + final success = await textsAfterSubmit(tester, ok()); + final limited = await textsAfterSubmit( + tester, + failed(const AuthFailure.emailSendLimited()), + ); + + expect(limited, success); + expect(limited, isNot(contains(_de.authErrorRateLimited))); + expect(limited, contains(_de.authCheckEmailDelayHint)); + }); + }); + + group('Zustände Kriterium 2, 3: loading, errors, offline', () { + testWidgets('while sending the button and fields are locked', ( + tester, + ) async { + final completer = Completer>(); + when(() => repository.signUp(any())).thenAnswer((_) => completer.future); + await open(tester); + await fillValid(tester); + + await tapKey(tester, 'register_submit'); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + tester.widget(fieldOf('register_email')).enabled, + false, + ); + final button = tester.widget( + find.descendant( + of: find.byKey(const Key('register_submit')), + matching: find.byType(FilledButton), + ), + ); + expect(button.onPressed, isNull); + + completer.complete(ok()); + await tester.pumpAndSettle(); + }); + + testWidgets('offline: shows the hint, keeps the input, can retry', ( + tester, + ) async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + final (:container, :router) = await open(tester); + await fillValid(tester); + + await submit(tester); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + expect(currentPath(router), '/register'); + expect( + tester.widget(fieldOf('register_email')).controller?.text, + 'anna@example.com', + ); + expect( + tester.widget(fieldOf('register_password')).controller?.text, + 'geheim-und-lang', + ); + + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + await tester.tap(find.text(_de.authActionRetry)); + await tester.pumpAndSettle(); + expect(currentPath(router), '/register/check-email'); + }); + + testWidgets('a rejected registration shows the general message', ( + tester, + ) async { + when(() => repository.signUp(any())).thenAnswer( + (_) async => failed(const AuthFailure.registrationRejected()), + ); + await open(tester); + await fillValid(tester); + + await submit(tester); + + expect(find.text(_de.authErrorRegistrationRejected), findsOneWidget); + }); + }); + + group('QUE-04 Kriterium 1: legal links', () { + testWidgets('privacy and terms open the configured URLs', (tester) async { + final opened = []; + await open( + tester, + urlOpener: (uri) async { + opened.add(uri); + return true; + }, + ); + + await tester.ensureVisible(find.text(_de.authCheckboxTermsPrivacyLink)); + await tester.tap(find.text(_de.authCheckboxTermsPrivacyLink)); + await tester.tap(find.text(_de.authCheckboxTermsTermsLink)); + await tester.pump(); + + expect(opened, [ + Uri.parse(testAppConfig().legalPrivacyUrl), + Uri.parse(testAppConfig().legalTermsUrl), + ]); + // Opening a link does not tick the box. + final handle = tester.ensureSemantics(); + expect( + tester.getSemantics(find.byKey(const Key('register_terms_checkbox'))), + isNot(matchesSemantics(isChecked: true)), + ); + handle.dispose(); + }); + + testWidgets('a failing launch shows a hint', (tester) async { + await open(tester, urlOpener: (_) async => false); + + await tester.ensureVisible(find.text(_de.authCheckboxTermsTermsLink)); + await tester.tap(find.text(_de.authCheckboxTermsTermsLink)); + await tester.pump(); + + expect(find.text(_de.authErrorUnexpected), findsOneWidget); + }); + }); + + group('QUE-05 Kriterium 2: de and en', () { + testWidgets( + 'KTO-01 Kriterium 11 (Client): english texts and english mail language', + (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authRegisterTitle), findsWidgets); + expect(find.text(_en.authFieldDisplayNameLabel), findsOneWidget); + expect(find.text(_en.authCheckboxAgeLabel), findsOneWidget); + expect(find.text(_de.authCheckboxAgeLabel), findsNothing); + + await fillValid(tester); + await submit(tester); + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.locale, 'en'); + expect(find.text(_en.authCheckEmailTitle), findsWidgets); + }, + ); + + testWidgets('german texts and the sentence with both links', ( + tester, + ) async { + await open(tester); + + expect(find.text(_de.authFieldPasswordLabel), findsOneWidget); + final handle = tester.ensureSemantics(); + expect( + find.bySemanticsLabel( + 'Ich habe die Datenschutzerklärung gelesen und stimme den ' + 'Nutzungsbedingungen zu', + ), + findsOneWidget, + ); + handle.dispose(); + }); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, themeMode: mode); + await submit(tester); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await open(tester, textScale: 2, size: const Size(320, 640)); + await submit(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('wide screens show a centered card', (tester) async { + await open(tester, size: const Size(1200, 900)); + + final card = tester.getRect(find.byType(Card).first); + expect(card.width, lessThanOrEqualTo(480)); + expect(card.center.dx, closeTo(600, 1)); + }); + }); +} diff --git a/docs/design-system.md b/docs/design-system.md index 6beead6..0a8dab5 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -71,7 +71,7 @@ getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vo | Komponente | Zweck und API (Kurzform) | |---|---| -| `AppTextField` | `label` (sichtbar über dem Feld, umbrechend, auch Semantics-Label), `helperText`, `errorText` (ersetzt Hilfetext), `controller`, `focusNode`, `keyboardType`, `textInputAction`, `autofillHints`, `maxLength` + `showCounter` (weiche Grenze: Eingabe wird nicht abgeschnitten, damit der Fehler am Feld erscheint), `inputFormatters`, `enabled`, `onChanged`, `onSubmitted`. | +| `AppTextField` | `label` (sichtbar über dem Feld, umbrechend, auch Semantics-Label), `helperText`, `errorText` (ersetzt Hilfetext), `controller`, `focusNode`, `keyboardType`, `textInputAction`, `autofillHints`, `maxLength` + `showCounter` (weiche Grenze: Eingabe wird nicht abgeschnitten, damit der Fehler am Feld erscheint), `counterText` (eigener Zählertext, wenn die Zähleinheit vom Standard abweicht, z. B. getrimmte Codepoints beim Anzeigenamen), `inputFormatters`, `enabled`, `onChanged`, `onSubmitted`. | | `AppPasswordField` | Wie `AppTextField`, dazu `showPasswordLabel` / `hidePasswordLabel` (Pflicht; Tooltip + Semantics des 48-dp-Schalters), `ruleHint` (Regelhinweis, solange kein Fehler), `isNewPassword` (Autofill `newPassword` statt `password`), `autofillHints` zum Überschreiben. Startet verdeckt, Autokorrektur und Vorschläge aus. | | `AppCheckboxField` | `AppCheckboxField(value, onChanged, label)` bzw. `.rich(segments: [AppLabelSegment.text(..), AppLabelSegment.link(text, onTap)])`, `errorText`. Ganze Zeile (Checkbox + Label) ist Touch-Ziel >= 48 dp. Links sind per Tastatur fokussierbar und für Screenreader als benutzerdefinierte Aktionen verfügbar; Semantics-Knoten meldet Zustand und den vollen Labeltext. | | `AppPrimaryButton` | `label`, `onPressed`, `isLoading` (deaktiviert + Spinner, kein Doppelsenden), `loadingLabel` (gesprochener Text im Ladezustand), `icon`, `expand` (Standard: volle Breite). |