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:
parent
54337ffc59
commit
e632da2344
45 changed files with 5255 additions and 54 deletions
9
app/lib/core/config/auth_config.dart
Normal file
9
app/lib/core/config/auth_config.dart
Normal 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);
|
||||||
|
}
|
||||||
14
app/lib/core/platform/platform_info.dart
Normal file
14
app/lib/core/platform/platform_info.dart
Normal 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);
|
||||||
63
app/lib/core/platform/platform_info.g.dart
Normal file
63
app/lib/core/platform/platform_info.g.dart
Normal 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';
|
||||||
21
app/lib/core/platform/url_opener.dart
Normal file
21
app/lib/core/platform/url_opener.dart
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
55
app/lib/core/platform/url_opener.g.dart
Normal file
55
app/lib/core/platform/url_opener.g.dart
Normal 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';
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shuffle/core/routing/app_routes.dart';
|
import 'package:shuffle/core/routing/app_routes.dart';
|
||||||
import 'package:shuffle/core/routing/not_found_screen.dart';
|
import 'package:shuffle/core/routing/not_found_screen.dart';
|
||||||
import 'package:shuffle/core/routing/redirect_source.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';
|
import 'package:shuffle/features/home/presentation/home_screen.dart';
|
||||||
|
|
||||||
part 'app_router.g.dart';
|
part 'app_router.g.dart';
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class AppTextField extends StatelessWidget {
|
||||||
this.suffixIcon,
|
this.suffixIcon,
|
||||||
this.maxLength,
|
this.maxLength,
|
||||||
this.showCounter = false,
|
this.showCounter = false,
|
||||||
|
this.counterText,
|
||||||
this.inputFormatters,
|
this.inputFormatters,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
|
|
@ -57,6 +58,11 @@ class AppTextField extends StatelessWidget {
|
||||||
|
|
||||||
/// Shows a `n/max` counter (needs [maxLength]).
|
/// Shows a `n/max` counter (needs [maxLength]).
|
||||||
final bool showCounter;
|
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 List<TextInputFormatter>? inputFormatters;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
|
|
@ -115,6 +121,7 @@ class AppTextField extends StatelessWidget {
|
||||||
onEditingComplete: onEditingComplete,
|
onEditingComplete: onEditingComplete,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
|
counterText: showCounter ? counterText : null,
|
||||||
error: hasError ? AppFieldError(errorText!) : null,
|
error: hasError ? AppFieldError(errorText!) : null,
|
||||||
helper: !hasError && helper != null && helper.isNotEmpty
|
helper: !hasError && helper != null && helper.isNotEmpty
|
||||||
? Text(
|
? Text(
|
||||||
|
|
|
||||||
19
app/lib/features/auth/domain/email_link_request.dart
Normal file
19
app/lib/features/auth/domain/email_link_request.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/lib/features/auth/presentation/auth_failure_texts.dart
Normal file
38
app/lib/features/auth/presentation/auth_failure_texts.dart
Normal 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(' ');
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.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
|
/// Placeholder for auth pages that 01b and 01c still deliver. Without visible
|
||||||
/// sichtbare Texte (keine hartkodierten UI-Strings).
|
/// texts (no hard-coded UI strings).
|
||||||
class AuthRoutePlaceholder extends StatelessWidget {
|
class AuthRoutePlaceholder extends StatelessWidget {
|
||||||
const AuthRoutePlaceholder({required this.routeKey, super.key});
|
const AuthRoutePlaceholder({required this.routeKey, super.key});
|
||||||
|
|
||||||
|
|
@ -14,27 +13,7 @@ class AuthRoutePlaceholder extends StatelessWidget {
|
||||||
Scaffold(key: Key('auth_placeholder_$routeKey'), body: const SizedBox());
|
Scaffold(key: Key('auth_placeholder_$routeKey'), body: const SizedBox());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Routen der Auth-Seiten nach Spec #1 § 5.3. A2 bis A4 ersetzen die Builder.
|
GoRoute authPlaceholderRoute(String path, String key) => GoRoute(
|
||||||
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(
|
|
||||||
path: path,
|
path: path,
|
||||||
builder: (context, state) => AuthRoutePlaceholder(routeKey: key),
|
builder: (context, state) => AuthRoutePlaceholder(routeKey: key),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
36
app/lib/features/auth/presentation/auth_routes.dart
Normal file
36
app/lib/features/auth/presentation/auth_routes.dart
Normal 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'),
|
||||||
|
];
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,9 +25,13 @@ AuthRepository authRepository(Ref ref) {
|
||||||
Stream<AuthStatus> authStatus(Ref ref) =>
|
Stream<AuthStatus> authStatus(Ref ref) =>
|
||||||
ref.watch(authRepositoryProvider).watchStatus();
|
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
|
/// 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:
|
||||||
@Riverpod(keepAlive: true)
|
/// 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) =>
|
Future<CommonPasswordList> commonPasswordList(Ref ref) =>
|
||||||
const AssetCommonPasswordList().load();
|
const AssetCommonPasswordList().load();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,13 +94,17 @@ final class AuthStatusProvider
|
||||||
String _$authStatusHash() => r'4dc863db6fda2e17d84d0c3ed5c9bc1808630293';
|
String _$authStatusHash() => r'4dc863db6fda2e17d84d0c3ed5c9bc1808630293';
|
||||||
|
|
||||||
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
|
/// 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)
|
@ProviderFor(commonPasswordList)
|
||||||
final commonPasswordListProvider = CommonPasswordListProvider._();
|
final commonPasswordListProvider = CommonPasswordListProvider._();
|
||||||
|
|
||||||
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
|
/// 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
|
final class CommonPasswordListProvider
|
||||||
extends
|
extends
|
||||||
|
|
@ -113,12 +117,14 @@ final class CommonPasswordListProvider
|
||||||
$FutureModifier<CommonPasswordList>,
|
$FutureModifier<CommonPasswordList>,
|
||||||
$FutureProvider<CommonPasswordList> {
|
$FutureProvider<CommonPasswordList> {
|
||||||
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
|
/// 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._()
|
CommonPasswordListProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
argument: null,
|
argument: null,
|
||||||
retry: null,
|
retry: _noRetry,
|
||||||
name: r'commonPasswordListProvider',
|
name: r'commonPasswordListProvider',
|
||||||
isAutoDispose: false,
|
isAutoDispose: false,
|
||||||
dependencies: null,
|
dependencies: null,
|
||||||
|
|
@ -141,7 +147,7 @@ final class CommonPasswordListProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$commonPasswordListHash() =>
|
String _$commonPasswordListHash() =>
|
||||||
r'964e79d4dc152c38ca77a55919cef3c14e5d3533';
|
r'39cdcf2bcde25bc7f4720c77feb7119febd2927f';
|
||||||
|
|
||||||
@ProviderFor(registrationInputFactory)
|
@ProviderFor(registrationInputFactory)
|
||||||
final registrationInputFactoryProvider = RegistrationInputFactoryProvider._();
|
final registrationInputFactoryProvider = RegistrationInputFactoryProvider._();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
309
app/lib/features/auth/presentation/register/register_screen.dart
Normal file
309
app/lib/features/auth/presentation/register/register_screen.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/lib/features/auth/presentation/register/register_state.dart
Normal file
115
app/lib/features/auth/presentation/register/register_state.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,64 @@
|
||||||
"homePlaceholderMessage": "Willkommen bei Shuffle. Der Brettspielkatalog folgt in Kürze.",
|
"homePlaceholderMessage": "Willkommen bei Shuffle. Der Brettspielkatalog folgt in Kürze.",
|
||||||
"notFoundTitle": "Seite nicht gefunden",
|
"notFoundTitle": "Seite nicht gefunden",
|
||||||
"notFoundMessage": "Diese Seite existiert nicht oder wurde verschoben.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,95 @@
|
||||||
"notFoundBackHome": "Back to start",
|
"notFoundBackHome": "Back to start",
|
||||||
"@notFoundBackHome": {
|
"@notFoundBackHome": {
|
||||||
"description": "Button on the 404 page that navigates to the start page"
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,360 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Back to start'**
|
/// **'Back to start'**
|
||||||
String get notFoundBackHome;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,210 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get notFoundBackHome => 'Zurück zum Start';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,207 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get notFoundBackHome => 'Back to start';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -851,7 +851,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
|
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ dependencies:
|
||||||
json_annotation: 4.12.0
|
json_annotation: 4.12.0
|
||||||
logging: 1.3.0
|
logging: 1.3.0
|
||||||
intl: 0.20.3
|
intl: 0.20.3
|
||||||
|
url_launcher: 6.3.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
10
app/test/core/config/auth_config_test.dart
Normal file
10
app/test/core/config/auth_config_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,20 @@ void main() {
|
||||||
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
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', (
|
testWidgets('exposes the label as semantics label of the text field', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -115,25 +115,24 @@ void main() {
|
||||||
expect(currentPath(router), '/');
|
expect(currentPath(router), '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('placeholder routes of spec 5.3 exist', (tester) async {
|
testWidgets(
|
||||||
|
'routes of spec 5.3 exist (01a: real screens, rest placeholders)',
|
||||||
|
(tester) async {
|
||||||
final (container: _, :router) = await start(tester);
|
final (container: _, :router) = await start(tester);
|
||||||
|
|
||||||
for (final (path, key) in [
|
for (final (path, key) in [
|
||||||
('/login', 'login'),
|
('/login', 'auth_placeholder_login'),
|
||||||
('/register', 'register'),
|
('/register', 'register_screen'),
|
||||||
('/register/check-email', 'check_email'),
|
('/register/check-email', 'check_email_screen'),
|
||||||
('/forgot-password', 'forgot_password'),
|
('/forgot-password', 'auth_placeholder_forgot_password'),
|
||||||
('/auth/confirm?token_hash=x&type=email', 'auth_confirm'),
|
('/auth/confirm?token_hash=x&type=email', 'email_link_screen'),
|
||||||
]) {
|
]) {
|
||||||
router.go(path);
|
router.go(path);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(find.byKey(Key(key)), findsOneWidget, reason: path);
|
||||||
find.byKey(Key('auth_placeholder_$key')),
|
|
||||||
findsOneWidget,
|
|
||||||
reason: path,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () {
|
group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () {
|
||||||
|
|
|
||||||
126
app/test/features/auth/presentation/auth_ui_support.dart
Normal file
126
app/test/features/auth/presentation/auth_ui_support.dart
Normal 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!,
|
||||||
|
];
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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('AB'), 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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', 'AB');
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vo
|
||||||
|
|
||||||
| Komponente | Zweck und API (Kurzform) |
|
| 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. |
|
| `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. |
|
| `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). |
|
| `AppPrimaryButton` | `label`, `onPressed`, `isLoading` (deaktiviert + Spinner, kein Doppelsenden), `loadingLabel` (gesprochener Text im Ladezustand), `icon`, `expand` (Standard: volle Breite). |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue