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 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 00:27:16 +01:00
parent 54337ffc59
commit e632da2344
45 changed files with 5255 additions and 54 deletions

View file

@ -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);
}

View file

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

View file

@ -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<bool, bool, bool>
with $Provider<bool> {
/// `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<bool> $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<bool>(value),
);
}
}
String _$isMobileWebHash() => r'd287a0a13cb71abe06b3d01885723f1d3ac520b1';

View file

@ -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<bool> 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;
}
};

View file

@ -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<UrlOpener, UrlOpener, UrlOpener>
with $Provider<UrlOpener> {
/// 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<UrlOpener> $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<UrlOpener>(value),
);
}
}
String _$urlOpenerHash() => r'f5f1e650e073776595fac2981548a91f815c66ca';

View file

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

View file

@ -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<TextInputFormatter>? 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(

View file

@ -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);
}
}

View file

@ -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<WeakPasswordReason> reasons,
) {
final texts = <String>{
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(' ');
}

View file

@ -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<RouteBase> 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),
);

View file

@ -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<RouteBase> 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'),
];

View file

@ -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<CheckEmailScreen> createState() => _CheckEmailScreenState();
}
class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
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;
}
}

View file

@ -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<void> 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);
}
}

View file

@ -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>(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<ResendState> get copyWith => _$ResendStateCopyWithImpl<ResendState>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View file

@ -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<ResendController, 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).
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<ResendState>(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> {
ResendState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<ResendState, ResendState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ResendState, ResendState>,
ResendState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -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<void> 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),
};
}
}

View file

@ -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>(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<EmailLinkState> get copyWith => _$EmailLinkStateCopyWithImpl<EmailLinkState>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View file

@ -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<EmailLinkController, 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).
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<EmailLinkState>(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<EmailLinkState> {
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<EmailLinkState, EmailLinkState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<EmailLinkState, EmailLinkState>,
EmailLinkState,
Object?,
Object?
>;
return element.handleCreate(ref, () => build(_$args.$1, _$args.$2));
}
}

View file

@ -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),
),
],
);
}
}

View file

@ -25,9 +25,13 @@ AuthRepository authRepository(Ref ref) {
Stream<AuthStatus> 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> commonPasswordList(Ref ref) =>
const AssetCommonPasswordList().load();

View file

@ -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<CommonPasswordList>,
$FutureProvider<CommonPasswordList> {
/// 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._();

View file

@ -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<void>? _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<void> loadCommonPasswords() => _commonPasswordsLoad ??= _load();
Future<void> _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<bool> 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<bool> 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);
}
}

View file

@ -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<RegisterController, RegisterState> {
/// 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<RegisterState>(value),
);
}
}
String _$registerControllerHash() =>
r'25930050b4b5fbaff535c3a09abe4ca15c860759';
/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10).
abstract class _$RegisterController extends $Notifier<RegisterState> {
RegisterState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<RegisterState, RegisterState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<RegisterState, RegisterState>,
RegisterState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -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<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
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<void> _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<void> _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<AppLabelSegment> _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,
);
}
}

View file

@ -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(<RegisterField>{}) Set<RegisterField> 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<WeakPasswordReason>? 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;
}

View file

@ -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>(T value) => value;
/// @nodoc
mixin _$RegisterState {
String get email; String get password; String get displayName; bool get ageConfirmed; bool get termsAccepted; Set<RegisterField> 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<WeakPasswordReason>? 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<RegisterState> get copyWith => _$RegisterStateCopyWithImpl<RegisterState>(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<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? 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<RegisterField>,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<WeakPasswordReason>?,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? 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 extends Object?>(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? 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 extends Object?>(TResult? Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? 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<RegisterField> visibleFields = const <RegisterField>{}, this.status = RegisterStatus.editing, this.commonPasswords, this.bannerFailure, List<WeakPasswordReason>? 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<RegisterField> _visibleFields;
@override@JsonKey() Set<RegisterField> 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<WeakPasswordReason>? _serverPasswordReasons;
/// `weak_password` reasons reported by the server, shown at the password.
@override List<WeakPasswordReason>? 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<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? 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<RegisterField>,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<WeakPasswordReason>?,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

View file

@ -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),
);
}
}

View file

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

View file

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

View file

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

View file

@ -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';
}

View file

@ -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';
}

View file

@ -851,7 +851,7 @@ packages:
source: hosted
version: "1.4.0"
url_launcher:
dependency: transitive
dependency: "direct main"
description:
name: url_launcher
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda

View file

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

View file

@ -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);
});
}

View file

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

View file

@ -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', () {

View file

@ -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<void, AuthFailure> ok() => const Result.success(null);
Result<void, AuthFailure> failed(AuthFailure value) => Result.failure(value);
/// Overrides for auth UI tests: repository mock, fixed language, common
/// password list, URL opener and platform.
List<Override> authUiOverrides(
MockAuthRepository repository, {
Locale locale = const Locale('de'),
UrlOpener? urlOpener,
bool mobileWeb = false,
Future<CommonPasswordList> Function()? commonPasswords,
List<Override> 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<Object?> 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<AuthUiHandle> 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<String> visibleTexts(WidgetTester tester) => [
for (final text in tester.widgetList<Text>(find.byType(Text)))
if (text.data != null) text.data!,
];

View file

@ -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<AuthUiHandle> 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<void> 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<Result<void, AuthFailure>>();
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);
});
});
}

View file

@ -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<void, AuthFailure> 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<Result<void, AuthFailure>>();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) => completer.future);
final first = notifier().resend(
'anna@example.com',
origin: ResendOrigin.checkEmail,
);
await Future<void>.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);
});
});
}

View file

@ -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);
},
);
});
}

View file

@ -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<AuthUiHandle> 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<void> 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<Result<void, AuthFailure>>();
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);
});
});
}

View file

@ -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<Uri> 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<CommonPasswordList> 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<Result<void, AuthFailure>>();
when(() => repository.signUp(any())).thenAnswer((_) => completer.future);
final container = makeContainer();
fillValid(notifier(container));
final first = notifier(container).submit();
await Future<void>.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<PasswordCommon>());
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<DisplayNameError?> 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');
});
}

View file

@ -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<AuthUiHandle> 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<void> type(WidgetTester tester, String key, String text) async {
await tester.ensureVisible(fieldOf(key));
await tester.enterText(fieldOf(key), text);
await tester.pump();
}
Future<void> tapKey(WidgetTester tester, String key) async {
final finder = find.byKey(Key(key));
await tester.ensureVisible(finder);
await tester.tap(finder);
await tester.pump();
}
Future<void> 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<void> 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<TextField>(fieldOf('register_email'));
expect(email.autofillHints, contains(AutofillHints.email));
expect(email.keyboardType, TextInputType.emailAddress);
expect(email.textInputAction, TextInputAction.next);
final password = tester.widget<TextField>(fieldOf('register_password'));
expect(password.autofillHints, contains(AutofillHints.newPassword));
expect(password.obscureText, isTrue);
final name = tester.widget<TextField>(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<TextField>(fieldOf('register_email'));
expect(email.focusNode?.hasFocus, isTrue);
});
});
group('KTO-01 Kriterium 4 (F12): same screen for every answer', () {
Future<List<String>> textsAfterSubmit(
WidgetTester tester,
Result<void, AuthFailure> 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<Result<void, AuthFailure>>();
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<TextField>(fieldOf('register_email')).enabled,
false,
);
final button = tester.widget<FilledButton>(
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<TextField>(fieldOf('register_email')).controller?.text,
'anna@example.com',
);
expect(
tester.widget<TextField>(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 = <Uri>[];
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));
});
});
}

View file

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