Compare commits

...

6 commits

Author SHA1 Message Date
fa7238a2ef Merge pull request 'feat(auth): KTO-01 Registrierung (01a) – Screens, Auth-Domäne, Formular-Komponenten (2/4)' (#34) from feature/1-01a-registrierung into main
All checks were successful
CI / flutter (push) Successful in 1m15s
CI / web-build (push) Successful in 1m2s
CI / supabase (push) Successful in 4s
CI / supabase-tests (push) Successful in 49s
Reviewed-on: #34
2026-11-03 07:56:15 +00:00
cschlaefke
239d0e1ac2 fix(auth): address review findings for registration
All checks were successful
CI / flutter (pull_request) Successful in 1m16s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 32s
- expose legal links to screen readers on web
- allow only http(s) legal URLs, prevent double submit
- detect transport errors without runtimeType, keep extra in memory
- ship third-party license text, document decisions

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:47:42 +01:00
cschlaefke
e632da2344 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>
2026-11-03 00:27:16 +01:00
cschlaefke
54337ffc59 feat(auth): add auth domain, repository, validators and router redirects
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:09:47 +01:00
cschlaefke
ec8b2ab4c1 feat(ui): add auth form components and screen layouts
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:03:11 +01:00
cschlaefke
3158abc344 docs(auth): use trim_ws in spec
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:58:11 +01:00
106 changed files with 22177 additions and 13 deletions

32
app/THIRD_PARTY.md Normal file
View file

@ -0,0 +1,32 @@
# Drittanbieter-Material in der App
## SecLists (Passwortliste)
Betrifft `assets/security/common-passwords.txt` (Herkunft und Filter:
`assets/security/README.md`). Lizenz: MIT, Wortlaut wie im Repository
https://github.com/danielmiessler/SecLists (Commit
`68bcd12457978649d41ae4f56e3b5d2db2b72560`):
```text
MIT License
Copyright (c) 2018 Daniel Miessler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

View file

@ -0,0 +1,45 @@
# Liste häufiger Passwörter
`common-passwords.txt` wird von der Registrierung und vom Passwort-Reset genutzt
(KTO-01 Kriterium 3, Spec #1 § 5.6). Die Liste wird nur im Client geprüft; es
gibt keinen Abgleich mit externen Diensten (kein HIBP).
## Format
- Eine Zeile je Eintrag, UTF-8, LF, Kleinschreibung, ohne Leerraum am Rand.
- Nur Einträge mit mindestens 10 Zeichen (kürzere fängt die Längenregel).
- Einmalig, nach Häufigkeit sortiert (deutsche Einträge zuerst).
- 10.000 Einträge, ca. 118 KB.
- Der Vergleich in der App erfolgt ohne Groß-/Kleinschreibung und nach Trim.
## Quelle und Lizenz
SecLists von Daniel Miessler, Lizenz MIT (Volltext in `app/THIRD_PARTY.md`),
Commit `68bcd12457978649d41ae4f56e3b5d2db2b72560`, Verzeichnis
`Passwords/Common-Credentials/`:
| Datei | Verwendung |
|---|---|
| `Language-Specific/German_common-password-list-top-100000.txt` | die ersten 4.000 Einträge mit mindestens 10 Zeichen |
| `xato-net-10-million-passwords-1000000.txt` | die ersten 6.000 weiteren Einträge mit mindestens 10 Zeichen |
**Lizenzfrage entschieden (Chris, 2026-11-03):** Die MIT-Lizenz gilt für das
SecLists-Repository. Die einzelnen Listen stammen aus Datenlecks bzw.
Sammlungen Dritter (die xato-Liste von Mark Burnett); eine eigene Lizenz je
Liste nennt SecLists nicht. Chris hat entschieden, die SecLists-Listen mit
dokumentierter Herkunft (diese Datei) und MIT-Lizenztext (`app/THIRD_PARTY.md`)
zu nutzen. Die Liste enthält ausschließlich Passwörter, keine Konten oder
E-Mail-Adressen.
Der Lizenztext wird zur Laufzeit über `LicenseRegistry` ausgeliefert
(`lib/core/licenses/third_party_licenses.dart`, Asset `THIRD_PARTY.md`) und
erscheint damit in `showLicensePage`.
## Neu erzeugen
```bash
python3 scripts/build-common-passwords.py <xato-1000000.txt> <german-top-100000.txt> app/assets/security/common-passwords.txt
```
Das Skript liegt unter `scripts/build-common-passwords.py` (Filter, Reihenfolge
und Grenzen stehen im Kopf der Datei).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
/// Auth settings the client has to know. They mirror the server configuration.
abstract final class AuthConfig {
/// Validity of confirmation and reset links (AUTH-CFG-07,
/// `GOTRUE_MAILER_OTP_EXP=3600`, see `docs/auth-konfiguration.md`).
///
/// The text `authCheckEmailValidityOneHour` is only correct while this is
/// one hour; a test pins the value.
static const linkValidity = Duration(hours: 1);
}

View file

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Asset with the license texts of bundled third-party material.
const thirdPartyAsset = 'THIRD_PARTY.md';
/// Package name under which the entry appears in `showLicensePage`.
const thirdPartyLicensePackage = 'SecLists (common passwords list)';
/// Registers the license text of the bundled password list (SecLists, MIT) in
/// the [LicenseRegistry], so that it shows up in `showLicensePage`. The text
/// is read lazily from [thirdPartyAsset] when the license page opens.
void registerThirdPartyLicenses({AssetBundle? bundle}) {
LicenseRegistry.addLicense(() async* {
final text = await (bundle ?? rootBundle).loadString(thirdPartyAsset);
yield LicenseEntryWithLineBreaks([thirdPartyLicensePackage], text);
});
}

View file

@ -0,0 +1,6 @@
library;
/// Detects `dart:io` network errors without importing `dart:io` on the web
/// (conditional import). Type checks with `is` keep working in minified
/// dart2js builds, unlike `runtimeType.toString()`.
export 'io_error_stub.dart' if (dart.library.io) 'io_error_io.dart';

View file

@ -0,0 +1,7 @@
import 'dart:io';
/// Network errors of `dart:io` (no connection, DNS, TLS handshake).
bool isIoNetworkError(Object error) =>
error is SocketException ||
error is HandshakeException ||
error is TlsException;

View file

@ -0,0 +1,2 @@
/// Web and other platforms without `dart:io`: no `dart:io` network errors.
bool isIoNetworkError(Object error) => false;

View file

@ -0,0 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'platform_info.g.dart';
/// `true` for the web app in a mobile browser (iOS or Android user agent).
///
/// Kept in one provider so that platform checks are not scattered over the
/// UI; tests override it.
@Riverpod(keepAlive: true)
bool isMobileWeb(Ref ref) =>
kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android);

View file

@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'platform_info.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// `true` for the web app in a mobile browser (iOS or Android user agent).
///
/// Kept in one provider so that platform checks are not scattered over the
/// UI; tests override it.
@ProviderFor(isMobileWeb)
final isMobileWebProvider = IsMobileWebProvider._();
/// `true` for the web app in a mobile browser (iOS or Android user agent).
///
/// Kept in one provider so that platform checks are not scattered over the
/// UI; tests override it.
final class IsMobileWebProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// `true` for the web app in a mobile browser (iOS or Android user agent).
///
/// Kept in one provider so that platform checks are not scattered over the
/// UI; tests override it.
IsMobileWebProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isMobileWebProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isMobileWebHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isMobileWeb(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isMobileWebHash() => r'd287a0a13cb71abe06b3d01885723f1d3ac520b1';

View file

@ -0,0 +1,38 @@
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');
/// Only `http`/`https` URLs with a host may leave the app (no `javascript:`,
/// `file:`, `intent:` and the like). The check lives here so that no caller
/// can forget it.
bool isOpenableExternalUrl(Uri uri) =>
(uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
/// Wraps [launch] with the scheme check of [isOpenableExternalUrl] and error
/// handling. Public for tests.
UrlOpener guardedUrlOpener(Future<bool> Function(Uri uri) launch) =>
(uri) async {
if (!isOpenableExternalUrl(uri)) {
_log.warning('refused to open url with scheme "${uri.scheme}"');
return false;
}
try {
return await launch(uri);
} on Object catch (error) {
_log.warning('launchUrl failed: ${error.runtimeType}');
return false;
}
};
/// The only place that touches `url_launcher`. Tests override this provider.
@Riverpod(keepAlive: true)
UrlOpener urlOpener(Ref ref) => guardedUrlOpener(
(uri) => launchUrl(uri, mode: LaunchMode.externalApplication),
);

View file

@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'url_opener.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// The only place that touches `url_launcher`. Tests override this provider.
@ProviderFor(urlOpener)
final urlOpenerProvider = UrlOpenerProvider._();
/// The only place that touches `url_launcher`. Tests override this provider.
final class UrlOpenerProvider
extends $FunctionalProvider<UrlOpener, UrlOpener, UrlOpener>
with $Provider<UrlOpener> {
/// The only place that touches `url_launcher`. Tests override this provider.
UrlOpenerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'urlOpenerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$urlOpenerHash();
@$internal
@override
$ProviderElement<UrlOpener> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
UrlOpener create(Ref ref) {
return urlOpener(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(UrlOpener value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<UrlOpener>(value),
);
}
}
String _$urlOpenerHash() => r'e86eb081c587907d3c162ed619b42abbbfb195a5';

View file

@ -3,6 +3,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/routing/not_found_screen.dart';
import 'package:shuffle/core/routing/redirect_source.dart';
import 'package:shuffle/core/routing/volatile_extra_codec.dart';
import 'package:shuffle/features/auth/presentation/auth_routes.dart';
import 'package:shuffle/features/home/presentation/home_screen.dart';
part 'app_router.g.dart';
@ -13,6 +15,7 @@ GoRouter appRouter(Ref ref) {
final router = GoRouter(
initialLocation: AppRoutes.home,
extraCodec: const VolatileExtraCodec(),
refreshListenable: source.changes,
redirect: (context, state) => source.redirect(state),
errorBuilder: (context, state) => const NotFoundScreen(),
@ -21,6 +24,7 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
),
...authRoutes,
],
);
ref.onDispose(router.dispose);

View file

@ -48,4 +48,4 @@ final class AppRouterProvider
}
}
String _$appRouterHash() => r'c3ba8cecc011ab4327ceb2284a58118355176803';
String _$appRouterHash() => r'39ea53d71fba02f9e9cc5e42df61144e55eca3d9';

View file

@ -1,4 +1,16 @@
/// Pfade der App. Weitere Routen kommen mit den Features.
abstract final class AppRoutes {
static const home = '/';
// Auth (Spec #1 § 5.3)
static const login = '/login';
static const register = '/register';
static const registerCheckEmail = '/register/check-email';
static const forgotPassword = '/forgot-password';
static const authConfirm = '/auth/confirm';
static const resetPassword = '/reset-password';
/// Query-Parameter der Anmeldeseite: Pfad, zu dem nach dem Login
/// zurückgekehrt wird.
static const fromParam = 'from';
}

View file

@ -0,0 +1,26 @@
import 'dart:convert';
/// `extraCodec` for go_router that does not persist `extra`.
///
/// go_router 18 JSON-encodes `extra` into the route information, and on the
/// web into the browser history state (`RouteMatchListCodec`,
/// `match.dart`). Data that must not outlive the page (here: the e-mail
/// address on "check your inbox") would stay in the history entry. With this
/// codec `extra` works in memory during navigation, but is dropped when the
/// state is serialized (reload, restoring from history).
class VolatileExtraCodec extends Codec<Object?, Object?> {
const VolatileExtraCodec();
@override
Converter<Object?, Object?> get encoder => const _DropConverter();
@override
Converter<Object?, Object?> get decoder => const _DropConverter();
}
class _DropConverter extends Converter<Object?, Object?> {
const _DropConverter();
@override
Object? convert(Object? input) => null;
}

View file

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
enum AppBannerType { info, error }
/// Inline message banner (info / error) above forms.
///
/// Type is conveyed by icon + [typeLabel] (spoken prefix, e.g. "Error"),
/// never by color alone. The message is a live region, so screen readers
/// announce it when it appears. The optional action is a separate 48 dp text
/// button. All texts come from the caller (l10n).
class AppBanner extends StatelessWidget {
const AppBanner({
required this.type,
required this.message,
required this.typeLabel,
super.key,
this.actionLabel,
this.onAction,
}) : assert(
(actionLabel == null) == (onAction == null),
'actionLabel and onAction belong together',
);
final AppBannerType type;
final String message;
final String typeLabel;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isError = type == AppBannerType.error;
final background = isError
? scheme.errorContainer
: scheme.secondaryContainer;
final foreground = isError
? scheme.onErrorContainer
: scheme.onSecondaryContainer;
return DecoratedBox(
decoration: BoxDecoration(
color: background,
borderRadius: AppRadius.mdAll,
border: Border.all(color: foreground.withValues(alpha: 0.5)),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
container: true,
liveRegion: true,
label: '$typeLabel: $message',
child: ExcludeSemantics(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isError ? Icons.error_outline : Icons.info_outline,
color: foreground,
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: foreground,
),
),
),
],
),
),
),
if (actionLabel != null)
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: onAction,
style: TextButton.styleFrom(foregroundColor: foreground),
child: Text(actionLabel!),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_field_error.dart';
/// A piece of a checkbox label: plain text or a link.
@immutable
class AppLabelSegment {
const AppLabelSegment.text(this.text) : onTap = null;
/// A link. It is focusable via keyboard and exposed to screen readers as its
/// own semantics node (`link: true`) with [text] as label.
const AppLabelSegment.link(this.text, this.onTap);
final String text;
final VoidCallback? onTap;
bool get isLink => onTap != null;
}
/// Checkbox with a label (optionally with embedded links) and error state.
///
/// The whole row (checkbox + label) toggles the value and is at least 48 dp
/// high. Links inside the label open their target instead of toggling.
/// [errorText] is shown with icon below the row and announced as live region.
class AppCheckboxField extends StatelessWidget {
AppCheckboxField({
required this.value,
required this.onChanged,
required String label,
super.key,
this.errorText,
this.enabled = true,
}) : segments = [AppLabelSegment.text(label)];
const AppCheckboxField.rich({
required this.value,
required this.onChanged,
required this.segments,
super.key,
this.errorText,
this.enabled = true,
});
final bool value;
final ValueChanged<bool>? onChanged;
final List<AppLabelSegment> segments;
final String? errorText;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasError = errorText != null && errorText!.isNotEmpty;
final interactive = enabled && onChanged != null;
final labelStyle = theme.textTheme.bodyLarge!;
final linkStyle = labelStyle.copyWith(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
);
final hasLinks = segments.any((s) => s.isLink);
final plainLabel = segments.map((s) => s.text).join();
final label = Text.rich(
TextSpan(
style: labelStyle,
children: [
for (final s in segments)
if (s.isLink)
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: _InlineLink(
text: s.text,
style: linkStyle,
onTap: s.onTap!,
),
)
else
// With links the text stays in the semantics tree (the links are its
// children) but is read via the checkbox label, not twice.
TextSpan(text: s.text, semanticsLabel: hasLinks ? '' : null),
],
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
container: true,
checked: value,
label: plainLabel,
enabled: interactive,
onTap: interactive ? () => onChanged!(!value) : null,
child: InkWell(
canRequestFocus: false,
excludeFromSemantics: true,
borderRadius: AppRadius.smAll,
onTap: interactive ? () => onChanged!(!value) : null,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSpacing.minTouchTarget,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ExcludeSemantics(
child: Checkbox(
value: value,
isError: hasError,
onChanged: interactive
? (v) => onChanged!(v ?? false)
: null,
),
),
const SizedBox(width: AppSpacing.xs),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
),
child: label,
),
),
],
),
),
),
),
if (hasError)
Padding(
padding: const EdgeInsets.only(
left: AppSpacing.md,
bottom: AppSpacing.xs,
),
child: AppFieldError(errorText!),
),
],
);
}
}
class _InlineLink extends StatelessWidget {
const _InlineLink({
required this.text,
required this.style,
required this.onTap,
});
final String text;
final TextStyle style;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// Own semantics node (`link: true`), so that screen readers reach the
// link on the web, too (custom actions are not mapped to ARIA there).
return Semantics(
container: true,
link: true,
label: text,
onTap: onTap,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm / 2),
focusColor: Theme.of(context).colorScheme.primary
.withValues(alpha: 0.2),
child: Text(text, style: style),
),
);
}
}

View file

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
/// Inline error message below a form control.
///
/// Information is never conveyed by color alone: icon + text. The text is a
/// polite live region, so screen readers announce it when it appears.
///
/// With [live] set to `false` the message stays visible and readable but is
/// not announced on its own. Use it for messages that change on every
/// keystroke (e.g. "n characters missing") and switch to `true` when the
/// field is left.
class AppFieldError extends StatelessWidget {
const AppFieldError(this.text, {super.key, this.live = true});
final String text;
final bool live;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.error;
return Semantics(
container: true,
liveRegion: live,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.only(top: AppSpacing.xxs),
child: Icon(Icons.error_outline, size: 16, color: color),
),
),
const SizedBox(width: AppSpacing.xs),
Expanded(
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(color: color),
),
),
],
),
);
}
}

View file

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
/// Page frame for form screens (login, register, ...).
///
/// Compact (< 600 dp): full-bleed, scrollable. From 600 dp: content in a
/// centered card of at most [maxWidth] (default
/// [AppBreakpoints.authCardMaxWidth]). The body scrolls, the keyboard pushes
/// the content up (Scaffold resizes) and drags dismiss the keyboard.
class AppFormScaffold extends StatelessWidget {
const AppFormScaffold({
required this.child,
super.key,
this.appBar,
this.maxWidth = AppBreakpoints.authCardMaxWidth,
});
final Widget child;
final PreferredSizeWidget? appBar;
final double maxWidth;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: appBar,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final carded = width >= AppBreakpoints.medium;
final content = carded
? Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: child,
),
)
: child;
return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: AppSpacing.pagePadding(width),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight - AppSpacing.lg * 2,
),
child: Align(
alignment: carded ? Alignment.center : Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: carded ? maxWidth : double.infinity,
),
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: SizedBox(width: double.infinity, child: content),
),
),
),
),
);
},
),
),
);
}
}

View file

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:shuffle/core/widgets/app_text_field.dart';
/// Password field with visibility toggle, rule hint and autofill hints.
///
/// The toggle is a 48 dp icon button with a tooltip/semantics label
/// ([showPasswordLabel] / [hidePasswordLabel], l10n from the caller). The
/// field is obscured initially. Use [isNewPassword] on register/reset so
/// password managers offer to generate a strong password.
class AppPasswordField extends StatefulWidget {
const AppPasswordField({
required this.label,
required this.showPasswordLabel,
required this.hidePasswordLabel,
super.key,
this.controller,
this.focusNode,
this.ruleHint,
this.errorText,
this.liveError = true,
this.isNewPassword = false,
this.autofillHints,
this.textInputAction,
this.enabled = true,
this.onChanged,
this.onSubmitted,
this.onEditingComplete,
});
final String label;
final String showPasswordLabel;
final String hidePasswordLabel;
final TextEditingController? controller;
final FocusNode? focusNode;
/// Rule hint (e.g. "at least 10 characters"), shown while there is no error.
final String? ruleHint;
final String? errorText;
/// See [AppTextField.liveError].
final bool liveError;
final bool isNewPassword;
/// Overrides the default hints (`password` / `newPassword`).
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
final bool enabled;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
@override
State<AppPasswordField> createState() => _AppPasswordFieldState();
}
class _AppPasswordFieldState extends State<AppPasswordField> {
bool _obscured = true;
@override
Widget build(BuildContext context) {
return AppTextField(
label: widget.label,
controller: widget.controller,
focusNode: widget.focusNode,
helperText: widget.ruleHint,
errorText: widget.errorText,
liveError: widget.liveError,
obscureText: _obscured,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
textInputAction: widget.textInputAction,
enabled: widget.enabled,
autofillHints:
widget.autofillHints ??
[
if (widget.isNewPassword)
AutofillHints.newPassword
else
AutofillHints.password,
],
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
onEditingComplete: widget.onEditingComplete,
suffixIcon: IconButton(
tooltip: _obscured
? widget.showPasswordLabel
: widget.hidePasswordLabel,
icon: Icon(_obscured ? Icons.visibility : Icons.visibility_off),
onPressed: widget.enabled
? () => setState(() => _obscured = !_obscured)
: null,
),
);
}
}

View file

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
/// Primary action button with loading state.
///
/// While [isLoading] the button is disabled (no double submit) and shows a
/// spinner next to the label. [loadingLabel] is the spoken label during
/// loading (l10n from the caller, e.g. "Registering..."); falls back to
/// [label]. Full width by default ([expand]); at least 48 dp high, grows with
/// the text scale.
class AppPrimaryButton extends StatelessWidget {
const AppPrimaryButton({
required this.label,
required this.onPressed,
super.key,
this.isLoading = false,
this.loadingLabel,
this.icon,
this.expand = true,
});
final String label;
final VoidCallback? onPressed;
final bool isLoading;
final String? loadingLabel;
final IconData? icon;
final bool expand;
@override
Widget build(BuildContext context) {
final enabled = onPressed != null && !isLoading;
final scheme = Theme.of(context).colorScheme;
final leading = isLoading
? SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: scheme.onSurface.withValues(alpha: 0.6),
),
)
: icon != null
? Icon(icon, size: 20)
: null;
final button = FilledButton(
onPressed: enabled ? onPressed : null,
child: Row(
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (leading != null) ...[
leading,
const SizedBox(width: AppSpacing.sm),
],
Flexible(child: Text(label, textAlign: TextAlign.center)),
],
),
);
return Semantics(
button: true,
enabled: enabled,
label: isLoading ? (loadingLabel ?? label) : label,
excludeSemantics: true,
onTap: enabled ? onPressed : null,
child: expand ? SizedBox(width: double.infinity, child: button) : button,
);
}
}

View file

@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_field_error.dart';
/// Single-line text field of the design system.
///
/// The [label] is always visible above the field and wraps (no clipping at
/// 200 % text scale). [errorText] replaces [helperText], is shown with icon
/// and error border and is announced as a live region. All strings come from
/// the caller (l10n).
class AppTextField extends StatelessWidget {
const AppTextField({
required this.label,
super.key,
this.controller,
this.focusNode,
this.helperText,
this.errorText,
this.liveError = true,
this.keyboardType,
this.textInputAction,
this.autofillHints,
this.textCapitalization = TextCapitalization.none,
this.autocorrect = true,
this.enableSuggestions = true,
this.obscureText = false,
this.suffixIcon,
this.maxLength,
this.showCounter = false,
this.counterText,
this.inputFormatters,
this.enabled = true,
this.autofocus = false,
this.onChanged,
this.onSubmitted,
this.onEditingComplete,
});
final String label;
final TextEditingController? controller;
final FocusNode? focusNode;
final String? helperText;
final String? errorText;
/// Whether [errorText] is announced as a live region. Set to `false` while
/// the user is typing and the message changes with every keystroke.
final bool liveError;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final Iterable<String>? autofillHints;
final TextCapitalization textCapitalization;
final bool autocorrect;
final bool enableSuggestions;
final bool obscureText;
/// Trailing widget inside the field (e.g. the password visibility toggle).
final Widget? suffixIcon;
/// Soft limit: input beyond it is not blocked, so the caller can show the
/// error and keep the user's input.
final int? maxLength;
/// Shows a `n/max` counter (needs [maxLength]).
final bool showCounter;
/// Optional text for the counter (e.g. "4/30"). Use it when the counted
/// unit differs from the default (graphemes of the raw text). Only shown
/// when [showCounter] is set.
final String? counterText;
final List<TextInputFormatter>? inputFormatters;
final bool enabled;
final bool autofocus;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasError = errorText != null && errorText!.isNotEmpty;
final helper = helperText;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
),
Semantics(
label: label,
child: TextField(
controller: controller,
focusNode: focusNode,
enabled: enabled,
autofocus: autofocus,
keyboardType: keyboardType,
textInputAction: textInputAction,
autofillHints: autofillHints,
textCapitalization: textCapitalization,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
inputFormatters: inputFormatters,
maxLength: maxLength,
maxLengthEnforcement: MaxLengthEnforcement.none,
buildCounter: showCounter && maxLength != null
? null
: (
_, {
required currentLength,
required isFocused,
maxLength,
}) => null,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
decoration: InputDecoration(
suffixIcon: suffixIcon,
counterText: showCounter ? counterText : null,
error: hasError
? AppFieldError(errorText!, live: liveError)
: null,
helper: !hasError && helper != null && helper.isNotEmpty
? Text(
helper,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
: null,
),
),
),
],
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:flutter/services.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
/// Lädt `assets/security/common-passwords.txt` (Herkunft:
/// `assets/security/README.md`).
class AssetCommonPasswordList {
const AssetCommonPasswordList({this.bundle});
static const assetPath = 'assets/security/common-passwords.txt';
final AssetBundle? bundle;
/// Liest und parst die Datei. Der Aufrufer lädt lazy (erster Zugriff auf ein
/// Passwortfeld) und hält das Ergebnis.
Future<CommonPasswordList> load() async {
final text = await (bundle ?? rootBundle).loadString(assetPath);
return parseCommonPasswords(text);
}
}
/// Zerlegt den Dateiinhalt: eine Zeile je Eintrag, Kleinschreibung, getrimmt,
/// leere Zeilen und `#`-Kommentare entfallen.
CommonPasswordList parseCommonPasswords(String text) {
final entries = <String>{};
for (final line in text.split('\n')) {
final entry = line.trim().toLowerCase();
if (entry.isNotEmpty && !entry.startsWith('#')) {
entries.add(entry);
}
}
return SetCommonPasswordList(entries);
}

View file

@ -0,0 +1,118 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:shuffle/core/platform/io_error.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Aufruf, bei dem ein Fehler auftrat. Manche Zuordnungen hängen davon ab
/// (z. B. HTTP 500 nur beim `signUp`).
enum AuthOperation {
signUp,
signIn,
resend,
requestPasswordReset,
verifyLink,
updatePassword,
}
/// Übersetzt Ausnahmen von `supabase_flutter` in [AuthFailure]
/// (Mapping-Tabelle: Spec #1 § 4.4).
///
/// Befund zu `gotrue` 2.27.3 (A1-Prüfung der Spec): Jede Antwort mit Status
/// >= 500 wird als [AuthRetryableFetchException] geworfen, mit dem rohen Body
/// in `message`, dem Status in `statusCode` und `code == null`. Ein
/// Trigger-Fehler beim `signUp` (HTTP 500) ist deshalb nur am Status
/// erkennbar; die Prüfung muss vor dem Netzwerkfall stehen.
AuthFailure mapAuthError(Object error, AuthOperation operation) {
if (error is AuthWeakPasswordException) {
return AuthFailure.weakPassword(_weakReasons(error.reasons));
}
if (error is AuthRetryableFetchException) {
if (operation == AuthOperation.signUp && error.statusCode == '500') {
return AuthFailure.registrationRejected(
reason: registrationRejectionReasonFrom(error.message),
);
}
// Netzwerkfehler (kein Status) und sonstige 5xx (Gateway, Ausfall).
return const AuthFailure.network();
}
if (error is AuthSessionMissingException) {
return const AuthFailure.unexpected();
}
if (error is AuthException) {
return _mapApiError(error, operation);
}
if (error is TimeoutException || _isTransportError(error)) {
return const AuthFailure.network();
}
return const AuthFailure.unexpected();
}
AuthFailure _mapApiError(AuthException error, AuthOperation operation) {
switch (error.code) {
case 'invalid_credentials':
return const AuthFailure.invalidCredentials();
case 'email_not_confirmed':
return const AuthFailure.emailNotConfirmed();
case 'over_email_send_rate_limit':
return switch (operation) {
AuthOperation.signUp ||
AuthOperation.requestPasswordReset ||
AuthOperation.resend => const AuthFailure.emailSendLimited(),
_ => const AuthFailure.rateLimited(),
};
case 'over_request_rate_limit':
return const AuthFailure.rateLimited();
case 'weak_password':
return const AuthFailure.weakPassword([]);
case 'same_password':
return const AuthFailure.samePassword();
case 'otp_expired':
return const AuthFailure.linkInvalidOrExpired();
case 'email_address_invalid' || 'validation_failed':
return const AuthFailure.invalidEmail();
}
if (error.statusCode == '429') {
return const AuthFailure.rateLimited();
}
if (operation == AuthOperation.verifyLink && error.statusCode == '403') {
return const AuthFailure.linkInvalidOrExpired();
}
return const AuthFailure.unexpected();
}
List<WeakPasswordReason> _weakReasons(List<String> reasons) => [
for (final reason in reasons)
switch (reason) {
'length' => WeakPasswordReason.length,
'characters' => WeakPasswordReason.characters,
'pwned' => WeakPasswordReason.pwned,
_ => WeakPasswordReason.unknown,
},
];
/// Liest den Grund aus der Trigger-Meldung (Body des HTTP-500). Best effort,
/// das Format ist nicht zugesichert.
RegistrationRejectionReason registrationRejectionReasonFrom(String message) {
if (message.contains('display_name_invalid')) {
return RegistrationRejectionReason.displayName;
}
if (message.contains('age_confirmation_required')) {
return RegistrationRejectionReason.ageConfirmation;
}
if (message.contains('terms_acceptance_required')) {
return RegistrationRejectionReason.termsAcceptance;
}
if (message.contains('terms_version_required')) {
return RegistrationRejectionReason.termsVersion;
}
return RegistrationRejectionReason.unknown;
}
/// Rohe Transportfehler. `gotrue` verpackt Transportfehler normalerweise
/// schon selbst; das hier ist die Rückfallebene. Erkennung per `is`
/// (`ClientException` aus `package:http`, `dart:io`-Fehler über bedingten
/// Import), nicht über `runtimeType`: Das bricht in minifiziertem dart2js.
bool _isTransportError(Object error) =>
error is http.ClientException || isIoNetworkError(error);

View file

@ -0,0 +1,47 @@
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
// `AuthUser` heißt auch in supabase_flutter; hier ist die Domänenklasse gemeint.
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
/// Bildet `AuthChangeEvent`s auf [AuthStatus] ab (Spec #1 § 5.5).
///
/// Abweichung von der Wortlaut-Zuordnung in § 5.5: Die Recovery bleibt
/// bestehen, bis `userUpdated` (Passwort geändert) oder `signedOut` eintrifft.
/// Ein `tokenRefreshed` oder `signedIn` während der Recovery hebt den Guard
/// nicht auf. Der Zustand lebt nur im Prozess (Spec #1 § 5.4, R12): Aus dem
/// `amr`-Claim lässt er sich nicht ableiten.
class AuthStatusTracker {
bool _recovery = false;
/// Status für [session] unter Berücksichtigung der laufenden Recovery.
AuthStatus statusFor(Session? session) {
final user = session?.user;
if (user == null) {
return const AuthStatus.signedOut();
}
final authUser = AuthUser(
id: user.id,
email: user.email,
emailConfirmed: user.emailConfirmedAt != null,
);
return _recovery
? AuthStatus.passwordRecovery(authUser)
: AuthStatus.signedIn(authUser);
}
/// Verarbeitet ein Ereignis und liefert den neuen Status.
AuthStatus handle(AuthChangeEvent event, Session? session) {
switch (event) {
case AuthChangeEvent.passwordRecovery:
_recovery = session != null;
case AuthChangeEvent.signedOut || AuthChangeEvent.userUpdated:
_recovery = false;
default:
break;
}
if (session == null) {
_recovery = false;
}
return statusFor(session);
}
}

View file

@ -0,0 +1,163 @@
import 'dart:async';
import 'package:shuffle/core/logging/app_logger.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/data/auth_failure_mapper.dart';
import 'package:shuffle/features/auth/data/auth_status_tracker.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/auth_status.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// [AuthRepository] gegen Supabase Auth (GoTrue).
///
/// Logs enthalten weder E-Mail-Adressen noch Passwörter noch Tokens, nur
/// Operation, Fehlerart, HTTP-Status und Fehlercode.
class SupabaseAuthRepository implements AuthRepository {
SupabaseAuthRepository(this._auth) {
_lastStatus = _tracker.statusFor(_auth.currentSession);
_subscription = _auth.onAuthStateChange.listen(
_onAuthState,
onError: (Object error, StackTrace stackTrace) {
_log.warning('auth state stream error: ${error.runtimeType}');
},
);
}
final GoTrueClient _auth;
final _tracker = AuthStatusTracker();
final _changes = StreamController<AuthStatus>.broadcast(sync: true);
final _log = AppLogger('AuthRepository');
late AuthStatus _lastStatus;
StreamSubscription<AuthState>? _subscription;
void _onAuthState(AuthState state) {
final next = _tracker.handle(state.event, state.session);
if (next != _lastStatus) {
_lastStatus = next;
_changes.add(next);
}
}
/// Beendet das Abonnieren der Auth-Ereignisse.
Future<void> dispose() async {
await _subscription?.cancel();
_subscription = null;
await _changes.close();
}
@override
AuthStatus get currentStatus => _lastStatus;
@override
Stream<AuthStatus> watchStatus() {
// ignore: close_sinks, wird vom Abonnenten über cancel geschlossen.
late StreamController<AuthStatus> controller;
StreamSubscription<AuthStatus>? inner;
controller = StreamController<AuthStatus>(
onListen: () {
controller.add(_lastStatus);
inner = _changes.stream.listen(controller.add);
},
onCancel: () => inner?.cancel(),
);
return controller.stream;
}
@override
Future<Result<void, AuthFailure>> signUp(RegistrationInput input) => _guard(
AuthOperation.signUp,
() => _auth.signUp(
email: input.email.trim(),
password: input.password,
data: <String, dynamic>{
'display_name': input.displayName.trim(),
// JSON-Booleans: Der Trigger vergleicht `meta ->> '…'` mit 'true'.
'age_confirmed': input.ageConfirmed,
'terms_accepted': input.termsAccepted,
'terms_version': input.termsVersion,
'locale': input.locale,
},
),
);
@override
Future<Result<void, AuthFailure>> resendConfirmation(String email) => _guard(
AuthOperation.resend,
() => _auth.resend(type: OtpType.signup, email: email.trim()),
);
@override
Future<Result<void, AuthFailure>> signIn(String email, String password) =>
_guard(
AuthOperation.signIn,
() => _auth.signInWithPassword(email: email.trim(), password: password),
);
@override
Future<Result<void, AuthFailure>> requestPasswordReset(String email) =>
_guard(
AuthOperation.requestPasswordReset,
() => _auth.resetPasswordForEmail(email.trim()),
);
@override
Future<Result<void, AuthFailure>> verifyEmailLink(
String tokenHash,
EmailLinkType type,
) => _guard(AuthOperation.verifyLink, () async {
final response = await _auth.verifyOTP(
tokenHash: tokenHash,
type: switch (type) {
EmailLinkType.confirmation => OtpType.email,
EmailLinkType.recovery => OtpType.recovery,
},
);
if (response.session == null) {
throw StateError('verifyOTP returned no session');
}
});
@override
Future<Result<void, AuthFailure>> setNewPassword(String password) => _guard(
AuthOperation.updatePassword,
() => _auth.updateUser(UserAttributes(password: password)),
);
@override
Future<void> signOut() async {
try {
await _auth.signOut();
} on Object catch (error) {
_log.warning('signOut failed: ${error.runtimeType}');
}
}
Future<Result<void, AuthFailure>> _guard(
AuthOperation operation,
Future<void> Function() action,
) async {
try {
await action();
return const Result.success(null);
} on Object catch (error) {
final failure = mapAuthError(error, operation);
_logFailure(operation, error, failure);
return Result.failure(failure);
}
}
void _logFailure(AuthOperation operation, Object error, AuthFailure failure) {
final status = error is AuthException ? error.statusCode : null;
final code = error is AuthException ? error.code : null;
final reason = failure is RegistrationRejected
? ' reason=${failure.reason.name}'
: '';
_log.warning(
'${operation.name} failed: ${failure.runtimeType} '
'error=${error.runtimeType} status=$status code=$code$reason',
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_failure.freezed.dart';
/// Grund einer abgelehnten Passwortstärke (`weak_password.reasons`).
enum WeakPasswordReason { length, characters, pwned, unknown }
/// Grund einer serverseitig abgelehnten Registrierung (Trigger-Meldung).
///
/// Nur zum Loggen und für Zusatzhinweise: Das Format der Meldung ist nicht
/// zugesichert (Spec #1 § 4.4), unbekannte Meldungen ergeben [unknown].
enum RegistrationRejectionReason {
displayName,
ageConfirmation,
termsAcceptance,
termsVersion,
unknown,
}
/// Fachliche Fehler der Auth-Abläufe (Mapping: Spec #1 § 4.4).
@freezed
sealed class AuthFailure with _$AuthFailure {
const factory AuthFailure.invalidCredentials() = InvalidCredentials;
const factory AuthFailure.emailNotConfirmed() = EmailNotConfirmed;
/// `over_email_send_rate_limit`. Wo die Adresse frei eingegeben wird, wird
/// das wie Erfolg behandelt (F12), siehe `resendFailureFor`.
const factory AuthFailure.emailSendLimited() = EmailSendLimited;
/// `over_request_rate_limit` und sonstiges HTTP 429.
const factory AuthFailure.rateLimited() = RateLimited;
const factory AuthFailure.weakPassword(List<WeakPasswordReason> reasons) =
WeakPassword;
const factory AuthFailure.samePassword() = SamePassword;
const factory AuthFailure.linkInvalidOrExpired() = LinkInvalidOrExpired;
const factory AuthFailure.invalidEmail() = InvalidEmail;
const factory AuthFailure.registrationRejected({
@Default(RegistrationRejectionReason.unknown)
RegistrationRejectionReason reason,
}) = RegistrationRejected;
const factory AuthFailure.network() = NetworkFailure;
const factory AuthFailure.unexpected() = Unexpected;
}

View file

@ -0,0 +1,659 @@
// 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 'auth_failure.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AuthFailure {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthFailure);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure()';
}
}
/// @nodoc
class $AuthFailureCopyWith<$Res> {
$AuthFailureCopyWith(AuthFailure _, $Res Function(AuthFailure) __);
}
/// Adds pattern-matching-related methods to [AuthFailure].
extension AuthFailurePatterns on AuthFailure {
/// 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( InvalidCredentials value)? invalidCredentials,TResult Function( EmailNotConfirmed value)? emailNotConfirmed,TResult Function( EmailSendLimited value)? emailSendLimited,TResult Function( RateLimited value)? rateLimited,TResult Function( WeakPassword value)? weakPassword,TResult Function( SamePassword value)? samePassword,TResult Function( LinkInvalidOrExpired value)? linkInvalidOrExpired,TResult Function( InvalidEmail value)? invalidEmail,TResult Function( RegistrationRejected value)? registrationRejected,TResult Function( NetworkFailure value)? network,TResult Function( Unexpected value)? unexpected,required TResult orElse(),}){
final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials(_that);case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed(_that);case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited(_that);case RateLimited() when rateLimited != null:
return rateLimited(_that);case WeakPassword() when weakPassword != null:
return weakPassword(_that);case SamePassword() when samePassword != null:
return samePassword(_that);case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired(_that);case InvalidEmail() when invalidEmail != null:
return invalidEmail(_that);case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that);case NetworkFailure() when network != null:
return network(_that);case Unexpected() when unexpected != null:
return unexpected(_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?>({required TResult Function( InvalidCredentials value) invalidCredentials,required TResult Function( EmailNotConfirmed value) emailNotConfirmed,required TResult Function( EmailSendLimited value) emailSendLimited,required TResult Function( RateLimited value) rateLimited,required TResult Function( WeakPassword value) weakPassword,required TResult Function( SamePassword value) samePassword,required TResult Function( LinkInvalidOrExpired value) linkInvalidOrExpired,required TResult Function( InvalidEmail value) invalidEmail,required TResult Function( RegistrationRejected value) registrationRejected,required TResult Function( NetworkFailure value) network,required TResult Function( Unexpected value) unexpected,}){
final _that = this;
switch (_that) {
case InvalidCredentials():
return invalidCredentials(_that);case EmailNotConfirmed():
return emailNotConfirmed(_that);case EmailSendLimited():
return emailSendLimited(_that);case RateLimited():
return rateLimited(_that);case WeakPassword():
return weakPassword(_that);case SamePassword():
return samePassword(_that);case LinkInvalidOrExpired():
return linkInvalidOrExpired(_that);case InvalidEmail():
return invalidEmail(_that);case RegistrationRejected():
return registrationRejected(_that);case NetworkFailure():
return network(_that);case Unexpected():
return unexpected(_that);}
}
/// 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( InvalidCredentials value)? invalidCredentials,TResult? Function( EmailNotConfirmed value)? emailNotConfirmed,TResult? Function( EmailSendLimited value)? emailSendLimited,TResult? Function( RateLimited value)? rateLimited,TResult? Function( WeakPassword value)? weakPassword,TResult? Function( SamePassword value)? samePassword,TResult? Function( LinkInvalidOrExpired value)? linkInvalidOrExpired,TResult? Function( InvalidEmail value)? invalidEmail,TResult? Function( RegistrationRejected value)? registrationRejected,TResult? Function( NetworkFailure value)? network,TResult? Function( Unexpected value)? unexpected,}){
final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials(_that);case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed(_that);case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited(_that);case RateLimited() when rateLimited != null:
return rateLimited(_that);case WeakPassword() when weakPassword != null:
return weakPassword(_that);case SamePassword() when samePassword != null:
return samePassword(_that);case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired(_that);case InvalidEmail() when invalidEmail != null:
return invalidEmail(_that);case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that);case NetworkFailure() when network != null:
return network(_that);case Unexpected() when unexpected != null:
return unexpected(_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()? invalidCredentials,TResult Function()? emailNotConfirmed,TResult Function()? emailSendLimited,TResult Function()? rateLimited,TResult Function( List<WeakPasswordReason> reasons)? weakPassword,TResult Function()? samePassword,TResult Function()? linkInvalidOrExpired,TResult Function()? invalidEmail,TResult Function( RegistrationRejectionReason reason)? registrationRejected,TResult Function()? network,TResult Function()? unexpected,required TResult orElse(),}) {final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials();case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed();case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited();case RateLimited() when rateLimited != null:
return rateLimited();case WeakPassword() when weakPassword != null:
return weakPassword(_that.reasons);case SamePassword() when samePassword != null:
return samePassword();case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired();case InvalidEmail() when invalidEmail != null:
return invalidEmail();case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that.reason);case NetworkFailure() when network != null:
return network();case Unexpected() when unexpected != null:
return unexpected();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?>({required TResult Function() invalidCredentials,required TResult Function() emailNotConfirmed,required TResult Function() emailSendLimited,required TResult Function() rateLimited,required TResult Function( List<WeakPasswordReason> reasons) weakPassword,required TResult Function() samePassword,required TResult Function() linkInvalidOrExpired,required TResult Function() invalidEmail,required TResult Function( RegistrationRejectionReason reason) registrationRejected,required TResult Function() network,required TResult Function() unexpected,}) {final _that = this;
switch (_that) {
case InvalidCredentials():
return invalidCredentials();case EmailNotConfirmed():
return emailNotConfirmed();case EmailSendLimited():
return emailSendLimited();case RateLimited():
return rateLimited();case WeakPassword():
return weakPassword(_that.reasons);case SamePassword():
return samePassword();case LinkInvalidOrExpired():
return linkInvalidOrExpired();case InvalidEmail():
return invalidEmail();case RegistrationRejected():
return registrationRejected(_that.reason);case NetworkFailure():
return network();case Unexpected():
return unexpected();}
}
/// 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()? invalidCredentials,TResult? Function()? emailNotConfirmed,TResult? Function()? emailSendLimited,TResult? Function()? rateLimited,TResult? Function( List<WeakPasswordReason> reasons)? weakPassword,TResult? Function()? samePassword,TResult? Function()? linkInvalidOrExpired,TResult? Function()? invalidEmail,TResult? Function( RegistrationRejectionReason reason)? registrationRejected,TResult? Function()? network,TResult? Function()? unexpected,}) {final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials();case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed();case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited();case RateLimited() when rateLimited != null:
return rateLimited();case WeakPassword() when weakPassword != null:
return weakPassword(_that.reasons);case SamePassword() when samePassword != null:
return samePassword();case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired();case InvalidEmail() when invalidEmail != null:
return invalidEmail();case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that.reason);case NetworkFailure() when network != null:
return network();case Unexpected() when unexpected != null:
return unexpected();case _:
return null;
}
}
}
/// @nodoc
class InvalidCredentials implements AuthFailure {
const InvalidCredentials();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is InvalidCredentials);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.invalidCredentials()';
}
}
/// @nodoc
class EmailNotConfirmed implements AuthFailure {
const EmailNotConfirmed();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailNotConfirmed);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.emailNotConfirmed()';
}
}
/// @nodoc
class EmailSendLimited implements AuthFailure {
const EmailSendLimited();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailSendLimited);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.emailSendLimited()';
}
}
/// @nodoc
class RateLimited implements AuthFailure {
const RateLimited();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RateLimited);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.rateLimited()';
}
}
/// @nodoc
class WeakPassword implements AuthFailure {
const WeakPassword( List<WeakPasswordReason> reasons): _reasons = reasons;
final List<WeakPasswordReason> _reasons;
List<WeakPasswordReason> get reasons {
if (_reasons is EqualUnmodifiableListView) return _reasons;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_reasons);
}
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$WeakPasswordCopyWith<WeakPassword> get copyWith => _$WeakPasswordCopyWithImpl<WeakPassword>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is WeakPassword&&const DeepCollectionEquality().equals(other.reasons, _reasons));
}
@override
int get hashCode {
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_reasons));
}
@override
String toString() {
return 'AuthFailure.weakPassword(reasons: $reasons)';
}
}
/// @nodoc
abstract mixin class $WeakPasswordCopyWith<$Res> implements $AuthFailureCopyWith<$Res> {
factory $WeakPasswordCopyWith(WeakPassword value, $Res Function(WeakPassword) _then) = _$WeakPasswordCopyWithImpl;
@useResult
$Res call({
List<WeakPasswordReason> reasons
});
}
/// @nodoc
class _$WeakPasswordCopyWithImpl<$Res>
implements $WeakPasswordCopyWith<$Res> {
_$WeakPasswordCopyWithImpl(this._self, this._then);
final WeakPassword _self;
final $Res Function(WeakPassword) _then;
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? reasons = null,}) {
return _then(WeakPassword(
null == reasons ? _self._reasons : reasons // ignore: cast_nullable_to_non_nullable
as List<WeakPasswordReason>,
));
}
}
/// @nodoc
class SamePassword implements AuthFailure {
const SamePassword();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SamePassword);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.samePassword()';
}
}
/// @nodoc
class LinkInvalidOrExpired implements AuthFailure {
const LinkInvalidOrExpired();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is LinkInvalidOrExpired);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.linkInvalidOrExpired()';
}
}
/// @nodoc
class InvalidEmail implements AuthFailure {
const InvalidEmail();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is InvalidEmail);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.invalidEmail()';
}
}
/// @nodoc
class RegistrationRejected implements AuthFailure {
const RegistrationRejected({this.reason = RegistrationRejectionReason.unknown});
@JsonKey() final RegistrationRejectionReason reason;
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RegistrationRejectedCopyWith<RegistrationRejected> get copyWith => _$RegistrationRejectedCopyWithImpl<RegistrationRejected>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RegistrationRejected&&(identical(other.reason, reason) || other.reason == reason));
}
@override
int get hashCode {
return Object.hash(runtimeType,reason);
}
@override
String toString() {
return 'AuthFailure.registrationRejected(reason: $reason)';
}
}
/// @nodoc
abstract mixin class $RegistrationRejectedCopyWith<$Res> implements $AuthFailureCopyWith<$Res> {
factory $RegistrationRejectedCopyWith(RegistrationRejected value, $Res Function(RegistrationRejected) _then) = _$RegistrationRejectedCopyWithImpl;
@useResult
$Res call({
RegistrationRejectionReason reason
});
}
/// @nodoc
class _$RegistrationRejectedCopyWithImpl<$Res>
implements $RegistrationRejectedCopyWith<$Res> {
_$RegistrationRejectedCopyWithImpl(this._self, this._then);
final RegistrationRejected _self;
final $Res Function(RegistrationRejected) _then;
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? reason = null,}) {
return _then(RegistrationRejected(
reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
as RegistrationRejectionReason,
));
}
}
/// @nodoc
class NetworkFailure implements AuthFailure {
const NetworkFailure();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is NetworkFailure);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.network()';
}
}
/// @nodoc
class Unexpected implements AuthFailure {
const Unexpected();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Unexpected);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.unexpected()';
}
}
// dart format on

View file

@ -0,0 +1,35 @@
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
/// Zugriff auf die Anmeldung (Implementierung: Supabase/GoTrue).
abstract interface class AuthRepository {
/// Aktueller Status gefolgt von allen Änderungen.
Stream<AuthStatus> watchStatus();
/// Aktueller Status, synchron (für Router-Redirects).
AuthStatus get currentStatus;
/// Registriert ein Konto. Jede 200-Antwort ist Erfolg; ob die Adresse schon
/// existiert, wird nicht ausgewertet (KTO-01 Kriterium 4).
Future<Result<void, AuthFailure>> signUp(RegistrationInput input);
Future<Result<void, AuthFailure>> resendConfirmation(String email);
Future<Result<void, AuthFailure>> signIn(String email, String password);
Future<Result<void, AuthFailure>> requestPasswordReset(String email);
Future<Result<void, AuthFailure>> verifyEmailLink(
String tokenHash,
EmailLinkType type,
);
Future<Result<void, AuthFailure>> setNewPassword(String password);
/// Meldet lokal ab (u. a. beim Abbruch der Recovery). Fehler werden nur
/// geloggt.
Future<void> signOut();
}

View file

@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
part 'auth_status.freezed.dart';
/// Anmeldestatus der App.
///
/// [AuthStatus.passwordRecovery] gilt nur im laufenden Prozess: Eine
/// Recovery-Sitzung ist nach einem Neustart nicht mehr von einer normalen
/// Anmeldung zu unterscheiden (Spec #1 § 5.4, Risiko R12).
@freezed
sealed class AuthStatus with _$AuthStatus {
const AuthStatus._();
const factory AuthStatus.signedOut() = SignedOut;
const factory AuthStatus.signedIn(AuthUser user) = SignedIn;
const factory AuthStatus.passwordRecovery(AuthUser user) = PasswordRecovery;
bool get isSignedOut => this is SignedOut;
bool get isPasswordRecovery => this is PasswordRecovery;
}

View file

@ -0,0 +1,367 @@
// 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 'auth_status.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AuthStatus {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthStatus);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthStatus()';
}
}
/// @nodoc
class $AuthStatusCopyWith<$Res> {
$AuthStatusCopyWith(AuthStatus _, $Res Function(AuthStatus) __);
}
/// Adds pattern-matching-related methods to [AuthStatus].
extension AuthStatusPatterns on AuthStatus {
/// 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( SignedOut value)? signedOut,TResult Function( SignedIn value)? signedIn,TResult Function( PasswordRecovery value)? passwordRecovery,required TResult orElse(),}){
final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut(_that);case SignedIn() when signedIn != null:
return signedIn(_that);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_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?>({required TResult Function( SignedOut value) signedOut,required TResult Function( SignedIn value) signedIn,required TResult Function( PasswordRecovery value) passwordRecovery,}){
final _that = this;
switch (_that) {
case SignedOut():
return signedOut(_that);case SignedIn():
return signedIn(_that);case PasswordRecovery():
return passwordRecovery(_that);}
}
/// 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( SignedOut value)? signedOut,TResult? Function( SignedIn value)? signedIn,TResult? Function( PasswordRecovery value)? passwordRecovery,}){
final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut(_that);case SignedIn() when signedIn != null:
return signedIn(_that);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_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()? signedOut,TResult Function( AuthUser user)? signedIn,TResult Function( AuthUser user)? passwordRecovery,required TResult orElse(),}) {final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut();case SignedIn() when signedIn != null:
return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_that.user);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?>({required TResult Function() signedOut,required TResult Function( AuthUser user) signedIn,required TResult Function( AuthUser user) passwordRecovery,}) {final _that = this;
switch (_that) {
case SignedOut():
return signedOut();case SignedIn():
return signedIn(_that.user);case PasswordRecovery():
return passwordRecovery(_that.user);}
}
/// 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()? signedOut,TResult? Function( AuthUser user)? signedIn,TResult? Function( AuthUser user)? passwordRecovery,}) {final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut();case SignedIn() when signedIn != null:
return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_that.user);case _:
return null;
}
}
}
/// @nodoc
class SignedOut extends AuthStatus {
const SignedOut(): super._();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SignedOut);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthStatus.signedOut()';
}
}
/// @nodoc
class SignedIn extends AuthStatus {
const SignedIn(this.user): super._();
final AuthUser user;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SignedInCopyWith<SignedIn> get copyWith => _$SignedInCopyWithImpl<SignedIn>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SignedIn&&(identical(other.user, user) || other.user == user));
}
@override
int get hashCode {
return Object.hash(runtimeType,user);
}
@override
String toString() {
return 'AuthStatus.signedIn(user: $user)';
}
}
/// @nodoc
abstract mixin class $SignedInCopyWith<$Res> implements $AuthStatusCopyWith<$Res> {
factory $SignedInCopyWith(SignedIn value, $Res Function(SignedIn) _then) = _$SignedInCopyWithImpl;
@useResult
$Res call({
AuthUser user
});
$AuthUserCopyWith<$Res> get user;
}
/// @nodoc
class _$SignedInCopyWithImpl<$Res>
implements $SignedInCopyWith<$Res> {
_$SignedInCopyWithImpl(this._self, this._then);
final SignedIn _self;
final $Res Function(SignedIn) _then;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? user = null,}) {
return _then(SignedIn(
null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
as AuthUser,
));
}
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthUserCopyWith<$Res> get user {
return $AuthUserCopyWith<$Res>(_self.user, (value) {
return _then(_self.copyWith(user: value));
});
}
}
/// @nodoc
class PasswordRecovery extends AuthStatus {
const PasswordRecovery(this.user): super._();
final AuthUser user;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PasswordRecoveryCopyWith<PasswordRecovery> get copyWith => _$PasswordRecoveryCopyWithImpl<PasswordRecovery>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordRecovery&&(identical(other.user, user) || other.user == user));
}
@override
int get hashCode {
return Object.hash(runtimeType,user);
}
@override
String toString() {
return 'AuthStatus.passwordRecovery(user: $user)';
}
}
/// @nodoc
abstract mixin class $PasswordRecoveryCopyWith<$Res> implements $AuthStatusCopyWith<$Res> {
factory $PasswordRecoveryCopyWith(PasswordRecovery value, $Res Function(PasswordRecovery) _then) = _$PasswordRecoveryCopyWithImpl;
@useResult
$Res call({
AuthUser user
});
$AuthUserCopyWith<$Res> get user;
}
/// @nodoc
class _$PasswordRecoveryCopyWithImpl<$Res>
implements $PasswordRecoveryCopyWith<$Res> {
_$PasswordRecoveryCopyWithImpl(this._self, this._then);
final PasswordRecovery _self;
final $Res Function(PasswordRecovery) _then;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? user = null,}) {
return _then(PasswordRecovery(
null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
as AuthUser,
));
}
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthUserCopyWith<$Res> get user {
return $AuthUserCopyWith<$Res>(_self.user, (value) {
return _then(_self.copyWith(user: value));
});
}
}
// dart format on

View file

@ -0,0 +1,13 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_user.freezed.dart';
/// Angemeldeter Nutzer, soweit die UI ihn braucht.
@freezed
abstract class AuthUser with _$AuthUser {
const factory AuthUser({
required String id,
required String? email,
required bool emailConfirmed,
}) = _AuthUser;
}

View file

@ -0,0 +1,285 @@
// 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 'auth_user.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AuthUser {
String get id; String? get email; bool get emailConfirmed;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AuthUserCopyWith<AuthUser> get copyWith => _$AuthUserCopyWithImpl<AuthUser>(this as AuthUser, _$identity);
@override
bool operator ==(Object other) {
final _this = this as AuthUser;
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthUser&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.emailConfirmed, _this.emailConfirmed) || other.emailConfirmed == _this.emailConfirmed));
}
@override
int get hashCode {
final _this = this as AuthUser;
return Object.hash(runtimeType,_this.id,_this.email,_this.emailConfirmed);
}
@override
String toString() {
final _this = this as AuthUser;
return 'AuthUser(id: ${_this.id}, email: ${_this.email}, emailConfirmed: ${_this.emailConfirmed})';
}
}
/// @nodoc
abstract mixin class $AuthUserCopyWith<$Res> {
factory $AuthUserCopyWith(AuthUser value, $Res Function(AuthUser) _then) = _$AuthUserCopyWithImpl;
@useResult
$Res call({
String id, String? email, bool emailConfirmed
});
}
/// @nodoc
class _$AuthUserCopyWithImpl<$Res>
implements $AuthUserCopyWith<$Res> {
_$AuthUserCopyWithImpl(this._self, this._then);
final AuthUser _self;
final $Res Function(AuthUser) _then;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? email = freezed,Object? emailConfirmed = null,}) {
return _then(AuthUser(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String?,emailConfirmed: null == emailConfirmed ? _self.emailConfirmed : emailConfirmed // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [AuthUser].
extension AuthUserPatterns on AuthUser {
/// 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( _AuthUser value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _AuthUser() 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( _AuthUser value) $default,){
final _that = this;
switch (_that) {
case _AuthUser():
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( _AuthUser value)? $default,){
final _that = this;
switch (_that) {
case _AuthUser() 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 id, String? email, bool emailConfirmed)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _AuthUser() when $default != null:
return $default(_that.id,_that.email,_that.emailConfirmed);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 id, String? email, bool emailConfirmed) $default,) {final _that = this;
switch (_that) {
case _AuthUser():
return $default(_that.id,_that.email,_that.emailConfirmed);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 id, String? email, bool emailConfirmed)? $default,) {final _that = this;
switch (_that) {
case _AuthUser() when $default != null:
return $default(_that.id,_that.email,_that.emailConfirmed);case _:
return null;
}
}
}
/// @nodoc
class _AuthUser implements AuthUser {
const _AuthUser({required this.id, required this.email, required this.emailConfirmed});
@override final String id;
@override final String? email;
@override final bool emailConfirmed;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AuthUserCopyWith<_AuthUser> get copyWith => __$AuthUserCopyWithImpl<_AuthUser>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthUser&&(identical(other.id, id) || other.id == id)&&(identical(other.email, email) || other.email == email)&&(identical(other.emailConfirmed, emailConfirmed) || other.emailConfirmed == emailConfirmed));
}
@override
int get hashCode {
return Object.hash(runtimeType,id,email,emailConfirmed);
}
@override
String toString() {
return 'AuthUser(id: $id, email: $email, emailConfirmed: $emailConfirmed)';
}
}
/// @nodoc
abstract mixin class _$AuthUserCopyWith<$Res> implements $AuthUserCopyWith<$Res> {
factory _$AuthUserCopyWith(_AuthUser value, $Res Function(_AuthUser) _then) = __$AuthUserCopyWithImpl;
@override @useResult
$Res call({
String id, String? email, bool emailConfirmed
});
}
/// @nodoc
class __$AuthUserCopyWithImpl<$Res>
implements _$AuthUserCopyWith<$Res> {
__$AuthUserCopyWithImpl(this._self, this._then);
final _AuthUser _self;
final $Res Function(_AuthUser) _then;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? email = freezed,Object? emailConfirmed = null,}) {
return _then(_AuthUser(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String?,emailConfirmed: null == emailConfirmed ? _self.emailConfirmed : emailConfirmed // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View file

@ -0,0 +1,19 @@
import 'package:shuffle/features/auth/domain/email_link_type.dart';
/// Parameters of a mail link `/auth/confirm?token_hash=…&type=…`.
class EmailLinkRequest {
const EmailLinkRequest({required this.tokenHash, required this.type});
final String tokenHash;
final EmailLinkType type;
/// `null` if the token is missing or the type is unknown.
static EmailLinkRequest? tryParse(String? tokenHash, String? type) {
final parsedType = EmailLinkType.tryParse(type);
final token = tokenHash?.trim();
if (token == null || token.isEmpty || parsedType == null) {
return null;
}
return EmailLinkRequest(tokenHash: token, type: parsedType);
}
}

View file

@ -0,0 +1,13 @@
/// Art des Mail-Links (`type` in `/auth/confirm?token_hash=…&type=…`).
enum EmailLinkType {
confirmation,
recovery;
/// Liest den `type`-Parameter. `email` und `signup` bedeuten Bestätigung
/// (beide akzeptiert, Spike B0 (b)). Alles andere: `null`.
static EmailLinkType? tryParse(String? value) => switch (value) {
'email' || 'signup' => EmailLinkType.confirmation,
'recovery' => EmailLinkType.recovery,
_ => null,
};
}

View file

@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'registration_input.freezed.dart';
/// Eingaben der Registrierung (Spec #1 § 5.2).
@freezed
abstract class RegistrationInput with _$RegistrationInput {
const factory RegistrationInput({
required String email,
required String password,
required String displayName,
required bool ageConfirmed,
required bool termsAccepted,
required String termsVersion,
/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache.
required String locale,
}) = _RegistrationInput;
}

View file

@ -0,0 +1,299 @@
// 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 'registration_input.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RegistrationInput {
String get email; String get password; String get displayName; bool get ageConfirmed; bool get termsAccepted; String get termsVersion;/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache.
String get locale;
/// Create a copy of RegistrationInput
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RegistrationInputCopyWith<RegistrationInput> get copyWith => _$RegistrationInputCopyWithImpl<RegistrationInput>(this as RegistrationInput, _$identity);
@override
bool operator ==(Object other) {
final _this = this as RegistrationInput;
return identical(this, other) || (other.runtimeType == runtimeType&&other is RegistrationInput&&(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)&&(identical(other.termsVersion, _this.termsVersion) || other.termsVersion == _this.termsVersion)&&(identical(other.locale, _this.locale) || other.locale == _this.locale));
}
@override
int get hashCode {
final _this = this as RegistrationInput;
return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,_this.termsVersion,_this.locale);
}
@override
String toString() {
final _this = this as RegistrationInput;
return 'RegistrationInput(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, termsVersion: ${_this.termsVersion}, locale: ${_this.locale})';
}
}
/// @nodoc
abstract mixin class $RegistrationInputCopyWith<$Res> {
factory $RegistrationInputCopyWith(RegistrationInput value, $Res Function(RegistrationInput) _then) = _$RegistrationInputCopyWithImpl;
@useResult
$Res call({
String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale
});
}
/// @nodoc
class _$RegistrationInputCopyWithImpl<$Res>
implements $RegistrationInputCopyWith<$Res> {
_$RegistrationInputCopyWithImpl(this._self, this._then);
final RegistrationInput _self;
final $Res Function(RegistrationInput) _then;
/// Create a copy of RegistrationInput
/// 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? termsVersion = null,Object? locale = null,}) {
return _then(RegistrationInput(
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,termsVersion: null == termsVersion ? _self.termsVersion : termsVersion // ignore: cast_nullable_to_non_nullable
as String,locale: null == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [RegistrationInput].
extension RegistrationInputPatterns on RegistrationInput {
/// 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( _RegistrationInput value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RegistrationInput() 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( _RegistrationInput value) $default,){
final _that = this;
switch (_that) {
case _RegistrationInput():
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( _RegistrationInput value)? $default,){
final _that = this;
switch (_that) {
case _RegistrationInput() 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, String termsVersion, String locale)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RegistrationInput() when $default != null:
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);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, String termsVersion, String locale) $default,) {final _that = this;
switch (_that) {
case _RegistrationInput():
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);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, String termsVersion, String locale)? $default,) {final _that = this;
switch (_that) {
case _RegistrationInput() when $default != null:
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);case _:
return null;
}
}
}
/// @nodoc
class _RegistrationInput implements RegistrationInput {
const _RegistrationInput({required this.email, required this.password, required this.displayName, required this.ageConfirmed, required this.termsAccepted, required this.termsVersion, required this.locale});
@override final String email;
@override final String password;
@override final String displayName;
@override final bool ageConfirmed;
@override final bool termsAccepted;
@override final String termsVersion;
/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache.
@override final String locale;
/// Create a copy of RegistrationInput
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RegistrationInputCopyWith<_RegistrationInput> get copyWith => __$RegistrationInputCopyWithImpl<_RegistrationInput>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RegistrationInput&&(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)&&(identical(other.termsVersion, termsVersion) || other.termsVersion == termsVersion)&&(identical(other.locale, locale) || other.locale == locale));
}
@override
int get hashCode {
return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,termsVersion,locale);
}
@override
String toString() {
return 'RegistrationInput(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, termsVersion: $termsVersion, locale: $locale)';
}
}
/// @nodoc
abstract mixin class _$RegistrationInputCopyWith<$Res> implements $RegistrationInputCopyWith<$Res> {
factory _$RegistrationInputCopyWith(_RegistrationInput value, $Res Function(_RegistrationInput) _then) = __$RegistrationInputCopyWithImpl;
@override @useResult
$Res call({
String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale
});
}
/// @nodoc
class __$RegistrationInputCopyWithImpl<$Res>
implements _$RegistrationInputCopyWith<$Res> {
__$RegistrationInputCopyWithImpl(this._self, this._then);
final _RegistrationInput _self;
final $Res Function(_RegistrationInput) _then;
/// Create a copy of RegistrationInput
/// 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? termsVersion = null,Object? locale = null,}) {
return _then(_RegistrationInput(
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,termsVersion: null == termsVersion ? _self.termsVersion : termsVersion // ignore: cast_nullable_to_non_nullable
as String,locale: null == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View file

@ -0,0 +1,30 @@
import 'package:shuffle/features/auth/domain/auth_failure.dart';
/// Von wo „E-Mail erneut senden“ ausgelöst wird (Spec #1 § 4.4, F12).
enum ResendOrigin {
/// CheckEmailScreen: Die Adresse kann frei eingegeben sein.
checkEmail,
/// LoginScreen nach `email_not_confirmed`: Das Passwort war richtig.
login,
}
/// Wendet die Regel aus F12 für „E-Mail erneut senden“ an.
///
/// `null` heißt: wie Erfolg behandeln. Aus dem LoginScreen wird
/// `emailSendLimited` zum sichtbaren Drosselungshinweis ([RateLimited]).
AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) {
if (failure is EmailSendLimited) {
return switch (origin) {
ResendOrigin.checkEmail => null,
ResendOrigin.login => const AuthFailure.rateLimited(),
};
}
return failure;
}
/// Registrierung und Passwort-Reset (freie Adresseingabe): `emailSendLimited`
/// zählt wie Erfolg (F12, KTO-01 Kriterium 4). Liefert `null` für „wie
/// Erfolg“, sonst den Fehler.
AuthFailure? openEmailInputFailureFor(AuthFailure failure) =>
failure is EmailSendLimited ? null : failure;

View file

@ -0,0 +1,20 @@
/// Liste häufiger Passwörter (Implementierung in `data/`).
abstract interface class CommonPasswordList {
/// `true`, wenn [password] auf der Liste steht. Vergleich ohne Groß- und
/// Kleinschreibung, nach Trim.
bool contains(String password);
}
/// Einfache In-Memory-Liste aus bereits normalisierten Einträgen
/// (Kleinschreibung, ohne Leerraum am Rand).
class SetCommonPasswordList implements CommonPasswordList {
const SetCommonPasswordList(this._entries);
final Set<String> _entries;
int get length => _entries.length;
@override
bool contains(String password) =>
_entries.contains(password.trim().toLowerCase());
}

View file

@ -0,0 +1,42 @@
/// Fehler des Anzeigenamens.
enum DisplayNameError {
empty,
tooLong,
/// Enthält U+200B (Zero Width Space). Die UI zeigt dieselbe Meldung wie für
/// einen ungültigen Namen (Spec #1 § 5.4), kein eigener Text.
invalidCharacter,
}
/// Regel für Anzeigenamen (KTO-01 Kriterium 10, KTO-02 Kriterium 1).
///
/// Identisch zum Server (`public.trim_ws` + `char_length`): Leerraum am Rand
/// wird entfernt (Dart `trim()` und `trim_ws` entfernen dieselben Zeichen,
/// auch NBSP und Tab). Danach 1 bis 30 Unicode-Codepoints, gezählt über
/// `runes`, nicht über `String.length` (UTF-16). U+200B wird nicht getrimmt,
/// sondern überall abgelehnt.
abstract final class DisplayNameRule {
static const maxLength = 30;
static const zeroWidthSpace = '​';
/// Der Wert, der gespeichert und gesendet wird.
static String normalize(String value) => value.trim();
/// Länge in Codepoints nach dem Trim (für den Zähler `n/30`).
static int length(String value) => normalize(value).runes.length;
static DisplayNameError? validate(String value) {
final name = normalize(value);
if (name.contains(zeroWidthSpace)) {
return DisplayNameError.invalidCharacter;
}
final count = name.runes.length;
if (count == 0) {
return DisplayNameError.empty;
}
if (count > maxLength) {
return DisplayNameError.tooLong;
}
return null;
}
}

View file

@ -0,0 +1,23 @@
/// Fehler der E-Mail-Adresse.
enum EmailError { empty, invalid }
/// Einfache Syntaxprüfung (Feinprüfung macht der Server): ein `@`, Text davor,
/// ein Punkt im Domänenteil, kein Leerraum.
abstract final class EmailFormat {
static const maxLength = 254;
static final _pattern = RegExp(r'^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$');
static String normalize(String value) => value.trim();
static EmailError? validate(String value) {
final email = normalize(value);
if (email.isEmpty) {
return EmailError.empty;
}
if (email.length > maxLength || !_pattern.hasMatch(email)) {
return EmailError.invalid;
}
return null;
}
}

View file

@ -0,0 +1,38 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
part 'password_policy.freezed.dart';
/// Fehler der Passwortregel.
@freezed
sealed class PasswordError with _$PasswordError {
/// Zu kurz; [missing] Zeichen fehlen noch.
const factory PasswordError.tooShort(int missing) = PasswordTooShort;
const factory PasswordError.common() = PasswordCommon;
}
/// Passwortregel (KTO-01 Kriterium 3): mindestens 10 Zeichen und nicht auf der
/// Liste häufiger Passwörter.
///
/// Die Länge zählt Unicode-Codepoints des unveränderten Passworts (Leerzeichen
/// sind gültige Zeichen und werden nicht entfernt). Nur der Listenvergleich
/// trimmt und ignoriert die Groß-/Kleinschreibung.
abstract final class PasswordPolicy {
static const minLength = 10;
/// Prüft [password]. Ist die Liste noch nicht geladen ([common] `null`),
/// läuft nur die Längenprüfung; vor dem Absenden mit Liste erneut prüfen.
static PasswordError? validate(
String password, {
CommonPasswordList? common,
}) {
final length = password.runes.length;
if (length < minLength) {
return PasswordError.tooShort(minLength - length);
}
if (common != null && common.contains(password)) {
return const PasswordError.common();
}
return null;
}
}

View file

@ -0,0 +1,275 @@
// 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 'password_policy.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PasswordError {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordError);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'PasswordError()';
}
}
/// @nodoc
class $PasswordErrorCopyWith<$Res> {
$PasswordErrorCopyWith(PasswordError _, $Res Function(PasswordError) __);
}
/// Adds pattern-matching-related methods to [PasswordError].
extension PasswordErrorPatterns on PasswordError {
/// 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( PasswordTooShort value)? tooShort,TResult Function( PasswordCommon value)? common,required TResult orElse(),}){
final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that);case PasswordCommon() when common != null:
return common(_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?>({required TResult Function( PasswordTooShort value) tooShort,required TResult Function( PasswordCommon value) common,}){
final _that = this;
switch (_that) {
case PasswordTooShort():
return tooShort(_that);case PasswordCommon():
return common(_that);}
}
/// 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( PasswordTooShort value)? tooShort,TResult? Function( PasswordCommon value)? common,}){
final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that);case PasswordCommon() when common != null:
return common(_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( int missing)? tooShort,TResult Function()? common,required TResult orElse(),}) {final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that.missing);case PasswordCommon() when common != null:
return common();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?>({required TResult Function( int missing) tooShort,required TResult Function() common,}) {final _that = this;
switch (_that) {
case PasswordTooShort():
return tooShort(_that.missing);case PasswordCommon():
return common();}
}
/// 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( int missing)? tooShort,TResult? Function()? common,}) {final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that.missing);case PasswordCommon() when common != null:
return common();case _:
return null;
}
}
}
/// @nodoc
class PasswordTooShort implements PasswordError {
const PasswordTooShort(this.missing);
final int missing;
/// Create a copy of PasswordError
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PasswordTooShortCopyWith<PasswordTooShort> get copyWith => _$PasswordTooShortCopyWithImpl<PasswordTooShort>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordTooShort&&(identical(other.missing, missing) || other.missing == missing));
}
@override
int get hashCode {
return Object.hash(runtimeType,missing);
}
@override
String toString() {
return 'PasswordError.tooShort(missing: $missing)';
}
}
/// @nodoc
abstract mixin class $PasswordTooShortCopyWith<$Res> implements $PasswordErrorCopyWith<$Res> {
factory $PasswordTooShortCopyWith(PasswordTooShort value, $Res Function(PasswordTooShort) _then) = _$PasswordTooShortCopyWithImpl;
@useResult
$Res call({
int missing
});
}
/// @nodoc
class _$PasswordTooShortCopyWithImpl<$Res>
implements $PasswordTooShortCopyWith<$Res> {
_$PasswordTooShortCopyWithImpl(this._self, this._then);
final PasswordTooShort _self;
final $Res Function(PasswordTooShort) _then;
/// Create a copy of PasswordError
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? missing = null,}) {
return _then(PasswordTooShort(
null == missing ? _self.missing : missing // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
class PasswordCommon implements PasswordError {
const PasswordCommon();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordCommon);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'PasswordError.common()';
}
}
// dart format on

View file

@ -0,0 +1,38 @@
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
/// User-facing text of an [AuthFailure] (Spec #1 § 4.4, texts from
/// `docs/design/auth-screens.md`).
///
/// `emailSendLimited` has no text of its own: screens treat it as success
/// (F12) or convert it to `rateLimited` beforehand.
String authFailureMessage(AppLocalizations l10n, AuthFailure failure) =>
switch (failure) {
InvalidCredentials() => l10n.authErrorInvalidCredentials,
EmailNotConfirmed() => l10n.authErrorEmailNotConfirmed,
EmailSendLimited() || RateLimited() => l10n.authErrorRateLimited,
WeakPassword(:final reasons) => weakPasswordMessage(l10n, reasons),
SamePassword() => l10n.authErrorSamePassword,
LinkInvalidOrExpired() => l10n.authErrorLinkInvalidOrExpired,
InvalidEmail() => l10n.authErrorEmailInvalid,
RegistrationRejected() => l10n.authErrorRegistrationRejected,
NetworkFailure() => l10n.authErrorNetwork,
Unexpected() => l10n.authErrorUnexpected,
};
/// One sentence per distinct reason; an empty list gets the generic text.
String weakPasswordMessage(
AppLocalizations l10n,
List<WeakPasswordReason> reasons,
) {
final texts = <String>{
for (final reason in reasons)
switch (reason) {
WeakPasswordReason.length => l10n.authErrorWeakPasswordLength,
WeakPasswordReason.pwned => l10n.authErrorWeakPasswordPwned,
WeakPasswordReason.characters ||
WeakPasswordReason.unknown => l10n.authErrorWeakPasswordCharacters,
},
};
return texts.isEmpty ? l10n.authErrorWeakPasswordCharacters : texts.join(' ');
}

View file

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Placeholder for auth pages that 01b and 01c still deliver. Without visible
/// texts (no hard-coded UI strings).
class AuthRoutePlaceholder extends StatelessWidget {
const AuthRoutePlaceholder({required this.routeKey, super.key});
final String routeKey;
@override
Widget build(BuildContext context) =>
Scaffold(key: Key('auth_placeholder_$routeKey'), body: const SizedBox());
}
GoRoute authPlaceholderRoute(String path, String key) => GoRoute(
path: path,
builder: (context, state) => AuthRoutePlaceholder(routeKey: key),
);

View file

@ -0,0 +1,38 @@
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. go_router would
// persist `extra` in the browser history; the router's
// `VolatileExtraCodec` prevents that. After a web reload it is gone;
// the screen then asks for it.
final extra = state.extra;
return CheckEmailScreen(email: extra is String ? extra : null);
},
),
],
),
authPlaceholderRoute(AppRoutes.forgotPassword, 'forgot_password'),
GoRoute(
path: AppRoutes.authConfirm,
builder: (context, state) => EmailLinkScreen(
tokenHash: state.uri.queryParameters['token_hash'],
type: state.uri.queryParameters['type'],
),
),
authPlaceholderRoute(AppRoutes.resetPassword, 'reset_password'),
];

View file

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/config/auth_config.dart';
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_banner.dart';
import 'package:shuffle/core/widgets/app_form_scaffold.dart';
import 'package:shuffle/core/widgets/app_primary_button.dart';
import 'package:shuffle/core/widgets/app_text_field.dart';
import 'package:shuffle/features/auth/domain/resend_origin.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// "Check your inbox" after the registration (`/register/check-email`,
/// KTO-01 Kriterium 1, 4 und 6).
///
/// The text is neutral: it does not reveal whether the address already has an
/// account (Kriterium 4). [email] comes from the router `extra`; after a web
/// reload it is missing and the user types it in for "resend".
class CheckEmailScreen extends ConsumerStatefulWidget {
const CheckEmailScreen({this.email, super.key});
final String? email;
@override
ConsumerState<CheckEmailScreen> createState() => _CheckEmailScreenState();
}
class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
final _emailController = TextEditingController();
bool get _hasKnownEmail => (widget.email ?? '').trim().isNotEmpty;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
void _resend() {
final email = _hasKnownEmail ? widget.email! : _emailController.text;
ref
.read(resendControllerProvider.notifier)
.resend(email, origin: ResendOrigin.checkEmail);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(resendControllerProvider);
final l10n = context.l10n;
final theme = Theme.of(context);
final failure = state.failure;
return AppFormScaffold(
key: const Key('check_email_screen'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthScreenTitle(l10n.authCheckEmailTitle),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.authCheckEmailBody,
key: const Key('check_email_body'),
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.authCheckEmailValidity(_validityText(l10n)),
key: const Key('check_email_validity'),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.authCheckEmailDelayHint,
key: const Key('check_email_delay_hint'),
style: theme.textTheme.bodyMedium,
),
if (state.sent) ...[
const SizedBox(height: AppSpacing.lg),
AppBanner(
key: const Key('check_email_resent_banner'),
type: AppBannerType.info,
typeLabel: l10n.authBannerTypeInfo,
message: l10n.authCheckEmailResent,
),
],
if (failure != null) ...[
const SizedBox(height: AppSpacing.lg),
AppBanner(
key: const Key('check_email_error_banner'),
type: AppBannerType.error,
typeLabel: l10n.authBannerTypeError,
message: authFailureMessage(l10n, failure),
),
],
if (!_hasKnownEmail) ...[
const SizedBox(height: AppSpacing.xl),
AppTextField(
key: const Key('check_email_email'),
label: l10n.authFieldEmailLabel,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.email],
autocorrect: false,
enableSuggestions: false,
enabled: !state.isSending,
errorText: state.emailError == null
? null
: l10n.authErrorEmailInvalid,
onChanged: (_) =>
ref.read(resendControllerProvider.notifier).clearEmailError(),
onSubmitted: (_) => _resend(),
),
],
const SizedBox(height: AppSpacing.xl),
AppPrimaryButton(
key: const Key('check_email_resend'),
label: l10n.authCheckEmailResend,
loadingLabel: l10n.authSubmitting,
isLoading: state.isSending,
onPressed: _resend,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
key: const Key('check_email_back_to_login'),
onPressed: () => context.go(AppRoutes.login),
child: Text(l10n.authBackToLogin),
),
],
),
);
}
String _validityText(AppLocalizations l10n) {
assert(
AuthConfig.linkValidity == const Duration(hours: 1),
'Update authCheckEmailValidityOneHour (AUTH-CFG-07 changed).',
);
return l10n.authCheckEmailValidityOneHour;
}
}

View file

@ -0,0 +1,66 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/resend_origin.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
part 'resend_controller.freezed.dart';
part 'resend_controller.g.dart';
@freezed
abstract class ResendState with _$ResendState {
const factory ResendState({
@Default(false) bool isSending,
/// The last request counts as sent (also for `emailSendLimited` from the
/// check-email screen, F12).
@Default(false) bool sent,
AuthFailure? failure,
/// Only for an address typed in by the user.
EmailError? emailError,
}) = _ResendState;
}
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
///
/// The caller states where the request comes from: only there the controller
/// decides whether `emailSendLimited` is shown (login) or treated as success
/// (check-email screen).
@riverpod
class ResendController extends _$ResendController {
@override
ResendState build() => const ResendState();
void clearEmailError() {
if (state.emailError != null) {
state = state.copyWith(emailError: null);
}
}
Future<void> resend(String email, {required ResendOrigin origin}) async {
if (state.isSending) {
return;
}
final normalized = EmailFormat.normalize(email);
final emailError = EmailFormat.validate(normalized);
if (emailError != null) {
state = ResendState(emailError: emailError);
return;
}
state = const ResendState(isSending: true);
final result = await ref
.read(authRepositoryProvider)
.resendConfirmation(normalized);
if (!ref.mounted) {
return;
}
final raw = result.errorOrNull;
final failure = raw == null ? null : resendFailureFor(raw, origin);
state = failure == null
? const ResendState(sent: true)
: ResendState(failure: failure);
}
}

View file

@ -0,0 +1,318 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'resend_controller.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ResendState {
bool get isSending;/// The last request counts as sent (also for `emailSendLimited` from the
/// check-email screen, F12).
bool get sent; AuthFailure? get failure;/// Only for an address typed in by the user.
EmailError? get emailError;
/// Create a copy of ResendState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ResendStateCopyWith<ResendState> get copyWith => _$ResendStateCopyWithImpl<ResendState>(this as ResendState, _$identity);
@override
bool operator ==(Object other) {
final _this = this as ResendState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ResendState&&(identical(other.isSending, _this.isSending) || other.isSending == _this.isSending)&&(identical(other.sent, _this.sent) || other.sent == _this.sent)&&(identical(other.failure, _this.failure) || other.failure == _this.failure)&&(identical(other.emailError, _this.emailError) || other.emailError == _this.emailError));
}
@override
int get hashCode {
final _this = this as ResendState;
return Object.hash(runtimeType,_this.isSending,_this.sent,_this.failure,_this.emailError);
}
@override
String toString() {
final _this = this as ResendState;
return 'ResendState(isSending: ${_this.isSending}, sent: ${_this.sent}, failure: ${_this.failure}, emailError: ${_this.emailError})';
}
}
/// @nodoc
abstract mixin class $ResendStateCopyWith<$Res> {
factory $ResendStateCopyWith(ResendState value, $Res Function(ResendState) _then) = _$ResendStateCopyWithImpl;
@useResult
$Res call({
bool isSending, bool sent, AuthFailure? failure, EmailError? emailError
});
$AuthFailureCopyWith<$Res>? get failure;
}
/// @nodoc
class _$ResendStateCopyWithImpl<$Res>
implements $ResendStateCopyWith<$Res> {
_$ResendStateCopyWithImpl(this._self, this._then);
final ResendState _self;
final $Res Function(ResendState) _then;
/// Create a copy of ResendState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isSending = null,Object? sent = null,Object? failure = freezed,Object? emailError = freezed,}) {
return _then(ResendState(
isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable
as bool,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable
as bool,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,emailError: freezed == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable
as EmailError?,
));
}
/// Create a copy of ResendState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthFailureCopyWith<$Res>? get failure {
if (_self.failure == null) {
return null;
}
return $AuthFailureCopyWith<$Res>(_self.failure!, (value) {
return _then(_self.copyWith(failure: value));
});
}
}
/// Adds pattern-matching-related methods to [ResendState].
extension ResendStatePatterns on ResendState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ResendState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ResendState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ResendState value) $default,){
final _that = this;
switch (_that) {
case _ResendState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ResendState value)? $default,){
final _that = this;
switch (_that) {
case _ResendState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ResendState() when $default != null:
return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError) $default,) {final _that = this;
switch (_that) {
case _ResendState():
return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError)? $default,) {final _that = this;
switch (_that) {
case _ResendState() when $default != null:
return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _:
return null;
}
}
}
/// @nodoc
class _ResendState implements ResendState {
const _ResendState({this.isSending = false, this.sent = false, this.failure, this.emailError});
@override@JsonKey() final bool isSending;
/// The last request counts as sent (also for `emailSendLimited` from the
/// check-email screen, F12).
@override@JsonKey() final bool sent;
@override final AuthFailure? failure;
/// Only for an address typed in by the user.
@override final EmailError? emailError;
/// Create a copy of ResendState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ResendStateCopyWith<_ResendState> get copyWith => __$ResendStateCopyWithImpl<_ResendState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ResendState&&(identical(other.isSending, isSending) || other.isSending == isSending)&&(identical(other.sent, sent) || other.sent == sent)&&(identical(other.failure, failure) || other.failure == failure)&&(identical(other.emailError, emailError) || other.emailError == emailError));
}
@override
int get hashCode {
return Object.hash(runtimeType,isSending,sent,failure,emailError);
}
@override
String toString() {
return 'ResendState(isSending: $isSending, sent: $sent, failure: $failure, emailError: $emailError)';
}
}
/// @nodoc
abstract mixin class _$ResendStateCopyWith<$Res> implements $ResendStateCopyWith<$Res> {
factory _$ResendStateCopyWith(_ResendState value, $Res Function(_ResendState) _then) = __$ResendStateCopyWithImpl;
@override @useResult
$Res call({
bool isSending, bool sent, AuthFailure? failure, EmailError? emailError
});
@override $AuthFailureCopyWith<$Res>? get failure;
}
/// @nodoc
class __$ResendStateCopyWithImpl<$Res>
implements _$ResendStateCopyWith<$Res> {
__$ResendStateCopyWithImpl(this._self, this._then);
final _ResendState _self;
final $Res Function(_ResendState) _then;
/// Create a copy of ResendState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isSending = null,Object? sent = null,Object? failure = freezed,Object? emailError = freezed,}) {
return _then(_ResendState(
isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable
as bool,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable
as bool,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,emailError: freezed == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable
as EmailError?,
));
}
/// Create a copy of ResendState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthFailureCopyWith<$Res>? get failure {
if (_self.failure == null) {
return null;
}
return $AuthFailureCopyWith<$Res>(_self.failure!, (value) {
return _then(_self.copyWith(failure: value));
});
}
}
// dart format on

View file

@ -0,0 +1,83 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'resend_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
///
/// The caller states where the request comes from: only there the controller
/// decides whether `emailSendLimited` is shown (login) or treated as success
/// (check-email screen).
@ProviderFor(ResendController)
final resendControllerProvider = ResendControllerProvider._();
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
///
/// The caller states where the request comes from: only there the controller
/// decides whether `emailSendLimited` is shown (login) or treated as success
/// (check-email screen).
final class ResendControllerProvider
extends $NotifierProvider<ResendController, ResendState> {
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
///
/// The caller states where the request comes from: only there the controller
/// decides whether `emailSendLimited` is shown (login) or treated as success
/// (check-email screen).
ResendControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'resendControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$resendControllerHash();
@$internal
@override
ResendController create() => ResendController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ResendState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ResendState>(value),
);
}
}
String _$resendControllerHash() => r'cab54661555bb641d3dc0fb1b06486b10f2e59e8';
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
///
/// The caller states where the request comes from: only there the controller
/// decides whether `emailSendLimited` is shown (login) or treated as success
/// (check-email screen).
abstract class _$ResendController extends $Notifier<ResendState> {
ResendState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<ResendState, ResendState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ResendState, ResendState>,
ResendState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -0,0 +1,63 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/email_link_request.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
part 'email_link_controller.freezed.dart';
part 'email_link_controller.g.dart';
enum EmailLinkPhase {
/// Waiting for the click.
ready,
submitting,
success,
/// Link missing, invalid, expired or used.
invalid,
}
@freezed
abstract class EmailLinkState with _$EmailLinkState {
const factory EmailLinkState({
required EmailLinkPhase phase,
EmailLinkRequest? request,
/// Failure that allows another try (network, rate limit, unexpected).
AuthFailure? failure,
}) = _EmailLinkState;
}
/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only
/// by [confirm] (a button press), never when the screen opens, so mail
/// scanners cannot use up the one-time token (Spec #1, E3).
@riverpod
class EmailLinkController extends _$EmailLinkController {
@override
EmailLinkState build(String? tokenHash, String? type) {
final request = EmailLinkRequest.tryParse(tokenHash, type);
return request == null
? const EmailLinkState(phase: EmailLinkPhase.invalid)
: EmailLinkState(phase: EmailLinkPhase.ready, request: request);
}
Future<void> confirm() async {
final request = state.request;
if (request == null || state.phase != EmailLinkPhase.ready) {
return;
}
state = state.copyWith(phase: EmailLinkPhase.submitting, failure: null);
final result = await ref
.read(authRepositoryProvider)
.verifyEmailLink(request.tokenHash, request.type);
if (!ref.mounted) {
return;
}
final failure = result.errorOrNull;
state = switch (failure) {
null => state.copyWith(phase: EmailLinkPhase.success),
LinkInvalidOrExpired() => state.copyWith(phase: EmailLinkPhase.invalid),
_ => state.copyWith(phase: EmailLinkPhase.ready, failure: failure),
};
}
}

View file

@ -0,0 +1,311 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'email_link_controller.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$EmailLinkState {
EmailLinkPhase get phase; EmailLinkRequest? get request;/// Failure that allows another try (network, rate limit, unexpected).
AuthFailure? get failure;
/// Create a copy of EmailLinkState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$EmailLinkStateCopyWith<EmailLinkState> get copyWith => _$EmailLinkStateCopyWithImpl<EmailLinkState>(this as EmailLinkState, _$identity);
@override
bool operator ==(Object other) {
final _this = this as EmailLinkState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailLinkState&&(identical(other.phase, _this.phase) || other.phase == _this.phase)&&(identical(other.request, _this.request) || other.request == _this.request)&&(identical(other.failure, _this.failure) || other.failure == _this.failure));
}
@override
int get hashCode {
final _this = this as EmailLinkState;
return Object.hash(runtimeType,_this.phase,_this.request,_this.failure);
}
@override
String toString() {
final _this = this as EmailLinkState;
return 'EmailLinkState(phase: ${_this.phase}, request: ${_this.request}, failure: ${_this.failure})';
}
}
/// @nodoc
abstract mixin class $EmailLinkStateCopyWith<$Res> {
factory $EmailLinkStateCopyWith(EmailLinkState value, $Res Function(EmailLinkState) _then) = _$EmailLinkStateCopyWithImpl;
@useResult
$Res call({
EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure
});
$AuthFailureCopyWith<$Res>? get failure;
}
/// @nodoc
class _$EmailLinkStateCopyWithImpl<$Res>
implements $EmailLinkStateCopyWith<$Res> {
_$EmailLinkStateCopyWithImpl(this._self, this._then);
final EmailLinkState _self;
final $Res Function(EmailLinkState) _then;
/// Create a copy of EmailLinkState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? phase = null,Object? request = freezed,Object? failure = freezed,}) {
return _then(EmailLinkState(
phase: null == phase ? _self.phase : phase // ignore: cast_nullable_to_non_nullable
as EmailLinkPhase,request: freezed == request ? _self.request : request // ignore: cast_nullable_to_non_nullable
as EmailLinkRequest?,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,
));
}
/// Create a copy of EmailLinkState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthFailureCopyWith<$Res>? get failure {
if (_self.failure == null) {
return null;
}
return $AuthFailureCopyWith<$Res>(_self.failure!, (value) {
return _then(_self.copyWith(failure: value));
});
}
}
/// Adds pattern-matching-related methods to [EmailLinkState].
extension EmailLinkStatePatterns on EmailLinkState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _EmailLinkState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _EmailLinkState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _EmailLinkState value) $default,){
final _that = this;
switch (_that) {
case _EmailLinkState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _EmailLinkState value)? $default,){
final _that = this;
switch (_that) {
case _EmailLinkState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _EmailLinkState() when $default != null:
return $default(_that.phase,_that.request,_that.failure);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure) $default,) {final _that = this;
switch (_that) {
case _EmailLinkState():
return $default(_that.phase,_that.request,_that.failure);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure)? $default,) {final _that = this;
switch (_that) {
case _EmailLinkState() when $default != null:
return $default(_that.phase,_that.request,_that.failure);case _:
return null;
}
}
}
/// @nodoc
class _EmailLinkState implements EmailLinkState {
const _EmailLinkState({required this.phase, this.request, this.failure});
@override final EmailLinkPhase phase;
@override final EmailLinkRequest? request;
/// Failure that allows another try (network, rate limit, unexpected).
@override final AuthFailure? failure;
/// Create a copy of EmailLinkState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$EmailLinkStateCopyWith<_EmailLinkState> get copyWith => __$EmailLinkStateCopyWithImpl<_EmailLinkState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmailLinkState&&(identical(other.phase, phase) || other.phase == phase)&&(identical(other.request, request) || other.request == request)&&(identical(other.failure, failure) || other.failure == failure));
}
@override
int get hashCode {
return Object.hash(runtimeType,phase,request,failure);
}
@override
String toString() {
return 'EmailLinkState(phase: $phase, request: $request, failure: $failure)';
}
}
/// @nodoc
abstract mixin class _$EmailLinkStateCopyWith<$Res> implements $EmailLinkStateCopyWith<$Res> {
factory _$EmailLinkStateCopyWith(_EmailLinkState value, $Res Function(_EmailLinkState) _then) = __$EmailLinkStateCopyWithImpl;
@override @useResult
$Res call({
EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure
});
@override $AuthFailureCopyWith<$Res>? get failure;
}
/// @nodoc
class __$EmailLinkStateCopyWithImpl<$Res>
implements _$EmailLinkStateCopyWith<$Res> {
__$EmailLinkStateCopyWithImpl(this._self, this._then);
final _EmailLinkState _self;
final $Res Function(_EmailLinkState) _then;
/// Create a copy of EmailLinkState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? phase = null,Object? request = freezed,Object? failure = freezed,}) {
return _then(_EmailLinkState(
phase: null == phase ? _self.phase : phase // ignore: cast_nullable_to_non_nullable
as EmailLinkPhase,request: freezed == request ? _self.request : request // ignore: cast_nullable_to_non_nullable
as EmailLinkRequest?,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,
));
}
/// Create a copy of EmailLinkState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthFailureCopyWith<$Res>? get failure {
if (_self.failure == null) {
return null;
}
return $AuthFailureCopyWith<$Res>(_self.failure!, (value) {
return _then(_self.copyWith(failure: value));
});
}
}
// dart format on

View file

@ -0,0 +1,130 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'email_link_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only
/// by [confirm] (a button press), never when the screen opens, so mail
/// scanners cannot use up the one-time token (Spec #1, E3).
@ProviderFor(EmailLinkController)
final emailLinkControllerProvider = EmailLinkControllerFamily._();
/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only
/// by [confirm] (a button press), never when the screen opens, so mail
/// scanners cannot use up the one-time token (Spec #1, E3).
final class EmailLinkControllerProvider
extends $NotifierProvider<EmailLinkController, EmailLinkState> {
/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only
/// by [confirm] (a button press), never when the screen opens, so mail
/// scanners cannot use up the one-time token (Spec #1, E3).
EmailLinkControllerProvider._({
required EmailLinkControllerFamily super.from,
required (String?, String?) super.argument,
}) : super(
retry: null,
name: r'emailLinkControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$emailLinkControllerHash();
@override
String toString() {
return r'emailLinkControllerProvider'
''
'$argument';
}
@$internal
@override
EmailLinkController create() => EmailLinkController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EmailLinkState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EmailLinkState>(value),
);
}
@override
bool operator ==(Object other) {
return other is EmailLinkControllerProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$emailLinkControllerHash() =>
r'90d3746ab8aa2ceb60122ee09c942cac6dd49249';
/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only
/// by [confirm] (a button press), never when the screen opens, so mail
/// scanners cannot use up the one-time token (Spec #1, E3).
final class EmailLinkControllerFamily extends $Family
with
$ClassFamilyOverride<
EmailLinkController,
EmailLinkState,
EmailLinkState,
EmailLinkState,
(String?, String?)
> {
EmailLinkControllerFamily._()
: super(
retry: null,
name: r'emailLinkControllerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only
/// by [confirm] (a button press), never when the screen opens, so mail
/// scanners cannot use up the one-time token (Spec #1, E3).
EmailLinkControllerProvider call(String? tokenHash, String? type) =>
EmailLinkControllerProvider._(argument: (tokenHash, type), from: this);
@override
String toString() => r'emailLinkControllerProvider';
}
/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only
/// by [confirm] (a button press), never when the screen opens, so mail
/// scanners cannot use up the one-time token (Spec #1, E3).
abstract class _$EmailLinkController extends $Notifier<EmailLinkState> {
late final _$args = ref.$arg as (String?, String?);
String? get tokenHash => _$args.$1;
String? get type => _$args.$2;
EmailLinkState build(String? tokenHash, String? type);
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<EmailLinkState, EmailLinkState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<EmailLinkState, EmailLinkState>,
EmailLinkState,
Object?,
Object?
>;
return element.handleCreate(ref, () => build(_$args.$1, _$args.$2));
}
}

View file

@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/core/platform/platform_info.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_banner.dart';
import 'package:shuffle/core/widgets/app_form_scaffold.dart';
import 'package:shuffle/core/widgets/app_primary_button.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/email_link/email_link_controller.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// Landing page of the mail links (`/auth/confirm?token_hash=…&type=…`,
/// KTO-01 Kriterium 1 und 8).
///
/// Redeeming needs a button press (Spec #1, E3): mail scanners that only open
/// the URL must not use up the one-time token.
class EmailLinkScreen extends ConsumerWidget {
const EmailLinkScreen({this.tokenHash, this.type, super.key});
final String? tokenHash;
final String? type;
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = emailLinkControllerProvider(tokenHash, type);
final state = ref.watch(provider);
final controller = ref.read(provider.notifier);
final l10n = context.l10n;
final theme = Theme.of(context);
final linkType = state.request?.type ?? EmailLinkType.tryParse(type);
final isRecovery = linkType == EmailLinkType.recovery;
final title = isRecovery ? l10n.authRecoveryTitle : l10n.authConfirmTitle;
final Widget content;
switch (state.phase) {
case EmailLinkPhase.invalid:
content = AppBanner(
key: const Key('email_link_invalid_banner'),
type: AppBannerType.error,
typeLabel: l10n.authBannerTypeError,
message: l10n.authErrorLinkInvalidOrExpired,
actionLabel: l10n.authActionRequestNewLink,
onAction: () => context.go(
isRecovery ? AppRoutes.forgotPassword : AppRoutes.login,
),
);
case EmailLinkPhase.success:
content = _Success(isRecovery: isRecovery);
case EmailLinkPhase.ready || EmailLinkPhase.submitting:
final failure = state.failure;
content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (failure != null) ...[
AppBanner(
key: const Key('email_link_error_banner'),
type: AppBannerType.error,
typeLabel: l10n.authBannerTypeError,
message: authFailureMessage(l10n, failure),
),
const SizedBox(height: AppSpacing.lg),
],
Text(
isRecovery ? l10n.authRecoveryBody : l10n.authConfirmBody,
key: const Key('email_link_body'),
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: AppSpacing.xl),
AppPrimaryButton(
key: const Key('email_link_submit'),
label: isRecovery
? l10n.authRecoverySubmit
: l10n.authConfirmSubmit,
loadingLabel: l10n.authSubmitting,
isLoading: state.phase == EmailLinkPhase.submitting,
onPressed: controller.confirm,
),
],
);
}
return AppFormScaffold(
key: const Key('email_link_screen'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthScreenTitle(title),
const SizedBox(height: AppSpacing.lg),
content,
],
),
);
}
}
class _Success extends ConsumerWidget {
const _Success({required this.isRecovery});
final bool isRecovery;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final mobileWeb = ref.watch(isMobileWebProvider);
// Recovery continues automatically: the status change sends the user to
// the "new password" page (router redirect).
if (isRecovery) {
return const SizedBox.shrink();
}
final message = mobileWeb
? '${l10n.authConfirmSuccess} ${l10n.authConfirmSuccessMobileWeb}'
: l10n.authConfirmSuccess;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppBanner(
key: const Key('email_link_success_banner'),
type: AppBannerType.info,
typeLabel: l10n.authBannerTypeInfo,
message: message,
),
const SizedBox(height: AppSpacing.xl),
AppPrimaryButton(
key: const Key('email_link_continue'),
label: l10n.authConfirmContinue,
onPressed: () => context.go(AppRoutes.home),
),
],
);
}
}

View file

@ -0,0 +1,67 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/config/app_config_provider.dart';
import 'package:shuffle/core/l10n/app_locale.dart';
import 'package:shuffle/core/supabase/supabase_client_provider.dart';
import 'package:shuffle/features/auth/data/asset_common_password_list.dart';
import 'package:shuffle/features/auth/data/supabase_auth_repository.dart';
import 'package:shuffle/features/auth/domain/auth_repository.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
part 'auth_providers.g.dart';
@Riverpod(keepAlive: true)
AuthRepository authRepository(Ref ref) {
final repository = SupabaseAuthRepository(
ref.watch(supabaseClientProvider).auth,
);
ref.onDispose(repository.dispose);
return repository;
}
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
@Riverpod(keepAlive: true)
Stream<AuthStatus> authStatus(Ref ref) =>
ref.watch(authRepositoryProvider).watchStatus();
Duration? _noRetry(int retryCount, Object error) => null;
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung:
/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server
/// erzwingt sie ebenfalls), statt das Absenden zu blockieren.
@Riverpod(keepAlive: true, retry: _noRetry)
Future<CommonPasswordList> commonPasswordList(Ref ref) =>
const AssetCommonPasswordList().load();
/// Baut die Registrierungseingabe mit `terms_version` aus der [AppConfig] und
/// der aktiven Oberflächensprache als `locale` (F5, F6, KTO-11 Kriterium 11).
typedef RegistrationInputFactory = RegistrationInput Function({
required String email,
required String password,
required String displayName,
required bool ageConfirmed,
required bool termsAccepted,
});
@riverpod
RegistrationInputFactory registrationInputFactory(Ref ref) {
final termsVersion = ref.watch(appConfigProvider).termsVersion;
final locale = ref.watch(appLocaleProvider).languageCode;
return ({
required email,
required password,
required displayName,
required ageConfirmed,
required termsAccepted,
}) => RegistrationInput(
email: email,
password: password,
displayName: displayName,
ageConfirmed: ageConfirmed,
termsAccepted: termsAccepted,
termsVersion: termsVersion,
locale: locale,
);
}

View file

@ -0,0 +1,198 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(authRepository)
final authRepositoryProvider = AuthRepositoryProvider._();
final class AuthRepositoryProvider
extends $FunctionalProvider<AuthRepository, AuthRepository, AuthRepository>
with $Provider<AuthRepository> {
AuthRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authRepositoryHash();
@$internal
@override
$ProviderElement<AuthRepository> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
AuthRepository create(Ref ref) {
return authRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AuthRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AuthRepository>(value),
);
}
}
String _$authRepositoryHash() => r'a8f4563b2d07ee4f38024d50bed6e844adc95398';
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
@ProviderFor(authStatus)
final authStatusProvider = AuthStatusProvider._();
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
final class AuthStatusProvider
extends
$FunctionalProvider<
AsyncValue<AuthStatus>,
AuthStatus,
Stream<AuthStatus>
>
with $FutureModifier<AuthStatus>, $StreamProvider<AuthStatus> {
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
AuthStatusProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authStatusProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authStatusHash();
@$internal
@override
$StreamProviderElement<AuthStatus> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<AuthStatus> create(Ref ref) {
return authStatus(ref);
}
}
String _$authStatusHash() => r'4dc863db6fda2e17d84d0c3ed5c9bc1808630293';
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung:
/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server
/// erzwingt sie ebenfalls), statt das Absenden zu blockieren.
@ProviderFor(commonPasswordList)
final commonPasswordListProvider = CommonPasswordListProvider._();
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung:
/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server
/// erzwingt sie ebenfalls), statt das Absenden zu blockieren.
final class CommonPasswordListProvider
extends
$FunctionalProvider<
AsyncValue<CommonPasswordList>,
CommonPasswordList,
FutureOr<CommonPasswordList>
>
with
$FutureModifier<CommonPasswordList>,
$FutureProvider<CommonPasswordList> {
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung:
/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server
/// erzwingt sie ebenfalls), statt das Absenden zu blockieren.
CommonPasswordListProvider._()
: super(
from: null,
argument: null,
retry: _noRetry,
name: r'commonPasswordListProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$commonPasswordListHash();
@$internal
@override
$FutureProviderElement<CommonPasswordList> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<CommonPasswordList> create(Ref ref) {
return commonPasswordList(ref);
}
}
String _$commonPasswordListHash() =>
r'39cdcf2bcde25bc7f4720c77feb7119febd2927f';
@ProviderFor(registrationInputFactory)
final registrationInputFactoryProvider = RegistrationInputFactoryProvider._();
final class RegistrationInputFactoryProvider
extends
$FunctionalProvider<
RegistrationInputFactory,
RegistrationInputFactory,
RegistrationInputFactory
>
with $Provider<RegistrationInputFactory> {
RegistrationInputFactoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'registrationInputFactoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$registrationInputFactoryHash();
@$internal
@override
$ProviderElement<RegistrationInputFactory> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
RegistrationInputFactory create(Ref ref) {
return registrationInputFactory(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(RegistrationInputFactory value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<RegistrationInputFactory>(value),
);
}
}
String _$registrationInputFactoryHash() =>
r'31d8018fd695e874cbde8247727de1d92e92766a';

View file

@ -0,0 +1,151 @@
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/routing/redirect_source.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
part 'auth_redirect.g.dart';
/// Seiten, die nur abgemeldete Nutzer sehen (angemeldet → Startseite bzw.
/// `from`).
const _guestOnlyPaths = {
AppRoutes.login,
AppRoutes.register,
AppRoutes.forgotPassword,
};
/// Pfade, die nie als `from`-Ziel zugelassen sind (Schleifenschutz).
const _neverFromPaths = {..._guestOnlyPaths, AppRoutes.resetPassword};
/// Redirect-Regeln der Auth-Story (Spec #1 § 5.3), rein und ohne Zustand.
///
/// [privatePaths] sind Pfadpräfixe privater Routen. Sie ist bewusst eine
/// explizite Liste (derzeit leer): Alles andere ist öffentlich, unbekannte
/// Pfade landen weiter auf der 404-Seite.
String? authRedirectFor(
AuthStatus status,
Uri location, {
Iterable<String> privatePaths = const [],
}) {
final path = location.path;
switch (status) {
case PasswordRecovery():
return path == AppRoutes.resetPassword ? null : AppRoutes.resetPassword;
case SignedIn():
if (path == AppRoutes.resetPassword) {
return AppRoutes.home;
}
if (_guestOnlyPaths.contains(path)) {
return safeFromPath(location.queryParameters[AppRoutes.fromParam]) ??
AppRoutes.home;
}
return null;
case SignedOut():
if (path == AppRoutes.resetPassword) {
return AppRoutes.login;
}
if (_isPrivate(path, privatePaths)) {
final target = location.hasQuery ? '$path?${location.query}' : path;
return Uri(
path: AppRoutes.login,
queryParameters: {AppRoutes.fromParam: target},
).toString();
}
return null;
}
}
bool _isPrivate(String path, Iterable<String> privatePaths) =>
privatePaths.any((prefix) => path == prefix || path.startsWith('$prefix/'));
/// Prüft den `from`-Parameter: nur interne Pfade (beginnen mit `/`, nicht mit
/// `//` oder `/\`), keine Auth-Seiten. Sonst `null`.
String? safeFromPath(String? from) {
if (from == null || !from.startsWith('/')) {
return null;
}
if (from.startsWith('//') || from.startsWith(r'/\')) {
return null;
}
final uri = Uri.tryParse(from);
if (uri == null || uri.hasScheme || uri.hasAuthority) {
return null;
}
// `/login/` is the same page as `/login` (go_router ignores the trailing
// slash), so it is not allowed either.
final path = uri.path.length > 1
? uri.path.replaceFirst(RegExp(r'/+$'), '')
: uri.path;
if (_neverFromPaths.contains(path) ||
path.startsWith(AppRoutes.authConfirm)) {
return null;
}
return from;
}
/// [RedirectSource] mit Anmeldestatus für den Router.
class AuthRedirectSource implements RedirectSource {
AuthRedirectSource({required this.readStatus, this.privatePaths = const []});
final AuthStatus Function() readStatus;
final Iterable<String> privatePaths;
final _changes = ValueNotifier<int>(0);
@override
Listenable get changes => _changes;
/// Löst eine Neubewertung der Redirects aus.
void notifyStatusChanged() => _changes.value++;
void dispose() => _changes.dispose();
@override
String? redirect(GoRouterState state) =>
authRedirectFor(readStatus(), state.uri, privatePaths: privatePaths);
}
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
@Riverpod(keepAlive: true)
List<String> privateRoutePaths(Ref ref) => const [];
/// Aufräumen beim Abmelden (`ref.invalidate(...)` privater Provider).
typedef SignedOutHandler = void Function(Ref ref);
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
@Riverpod(keepAlive: true)
List<SignedOutHandler> signedOutHandlers(Ref ref) => const [];
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
@Riverpod(keepAlive: true)
AuthRedirectSource authRedirectSource(Ref ref) {
final source = AuthRedirectSource(
readStatus: () => ref.read(authRepositoryProvider).currentStatus,
privatePaths: ref.watch(privateRoutePathsProvider),
);
ref
..onDispose(source.dispose)
..listen(authStatusProvider, (previous, next) {
final before = previous?.value;
final after = next.value;
if (before != null && before is! SignedOut && after is SignedOut) {
for (final handler in ref.read(signedOutHandlersProvider)) {
handler(ref);
}
}
source.notifyStatusChanged();
});
return source;
}
/// Overrides, die die Auth-Redirects in den Router einhängen (in `main`).
List<Override> authOverrides() => [
redirectSourceProvider.overrideWith(
(ref) => ref.watch(authRedirectSourceProvider),
),
];

View file

@ -0,0 +1,172 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_redirect.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
@ProviderFor(privateRoutePaths)
final privateRoutePathsProvider = PrivateRoutePathsProvider._();
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
final class PrivateRoutePathsProvider
extends $FunctionalProvider<List<String>, List<String>, List<String>>
with $Provider<List<String>> {
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
PrivateRoutePathsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'privateRoutePathsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$privateRoutePathsHash();
@$internal
@override
$ProviderElement<List<String>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
List<String> create(Ref ref) {
return privateRoutePaths(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<String>>(value),
);
}
}
String _$privateRoutePathsHash() => r'21992f8c0bb0719b440ddc40c118e14d79617c29';
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
@ProviderFor(signedOutHandlers)
final signedOutHandlersProvider = SignedOutHandlersProvider._();
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
final class SignedOutHandlersProvider
extends
$FunctionalProvider<
List<SignedOutHandler>,
List<SignedOutHandler>,
List<SignedOutHandler>
>
with $Provider<List<SignedOutHandler>> {
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
SignedOutHandlersProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'signedOutHandlersProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$signedOutHandlersHash();
@$internal
@override
$ProviderElement<List<SignedOutHandler>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
List<SignedOutHandler> create(Ref ref) {
return signedOutHandlers(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<SignedOutHandler> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<SignedOutHandler>>(value),
);
}
}
String _$signedOutHandlersHash() => r'731ddc5c0c4d0aec1d144773b7aae23ce86321c8';
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
@ProviderFor(authRedirectSource)
final authRedirectSourceProvider = AuthRedirectSourceProvider._();
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
final class AuthRedirectSourceProvider
extends
$FunctionalProvider<
AuthRedirectSource,
AuthRedirectSource,
AuthRedirectSource
>
with $Provider<AuthRedirectSource> {
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
AuthRedirectSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authRedirectSourceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authRedirectSourceHash();
@$internal
@override
$ProviderElement<AuthRedirectSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AuthRedirectSource create(Ref ref) {
return authRedirectSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AuthRedirectSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AuthRedirectSource>(value),
);
}
}
String _$authRedirectSourceHash() =>
r'c543c4be331cebf1f7e4865330293021265a336b';

View file

@ -0,0 +1,167 @@
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;
}
// Set before the first await, otherwise a second trigger (button and
// Enter key) passes the guard while the list is still loading.
state = state.copyWith(status: RegisterStatus.submitting);
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(
status: RegisterStatus.editing,
focusField: invalid,
focusRequest: state.focusRequest + 1,
);
return false;
}
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 || !isOpenableExternalUrl(uri)) {
_log.warning('invalid legal url for ${document.name}');
return false;
}
return ref.read(urlOpenerProvider)(uri);
}
}

View file

@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'register_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10).
@ProviderFor(RegisterController)
final registerControllerProvider = RegisterControllerProvider._();
/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10).
final class RegisterControllerProvider
extends $NotifierProvider<RegisterController, RegisterState> {
/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10).
RegisterControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'registerControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$registerControllerHash();
@$internal
@override
RegisterController create() => RegisterController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(RegisterState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<RegisterState>(value),
);
}
}
String _$registerControllerHash() =>
r'78a2a99ae36292286f26aff1f9910504bc464636';
/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10).
abstract class _$RegisterController extends $Notifier<RegisterState> {
RegisterState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<RegisterState, RegisterState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<RegisterState, RegisterState>,
RegisterState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -0,0 +1,331 @@
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();
/// The user is typing in the password field (see `liveError` below).
bool _passwordTyping = false;
@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);
if (!_passwordFocus.hasFocus && _passwordTyping && mounted) {
// Field left: the length hint may be announced now.
setState(() => _passwordTyping = false);
}
});
_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),
// "n missing" changes with every keystroke: announce it only
// when the field is left. The visible hint stays.
liveError: !_passwordTyping || !_isLengthHintOnly(state),
onChanged: (value) {
if (!_passwordTyping) {
setState(() => _passwordTyping = true);
}
controller.passwordChanged(value);
},
),
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;
}
/// The only password message shown is the "n missing" count (no server
/// answer, not "required").
bool _isLengthHintOnly(RegisterState state) =>
state.serverPasswordReasons == null &&
!state.shownPasswordRequired &&
state.shownPasswordError is PasswordTooShort;
String? _passwordError(AppLocalizations l10n, RegisterState state) {
if (state.serverPasswordReasons case final reasons?) {
return weakPasswordMessage(l10n, reasons);
}
if (state.shownPasswordRequired) {
return l10n.authPasswordRequired;
}
return switch (state.shownPasswordError) {
PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing),
PasswordCommon() => l10n.authPasswordCommon,
null => null,
};
}
String? _displayNameError(AppLocalizations l10n, RegisterState state) =>
switch (state.shownDisplayNameError) {
DisplayNameError.empty => l10n.authDisplayNameEmpty,
DisplayNameError.tooLong => l10n.authDisplayNameTooLong(
DisplayNameRule.maxLength,
),
// U+200B: same message as "invalid" (Spec #1 § 5.4).
DisplayNameError.invalidCharacter => l10n.authDisplayNameInvalid,
null => null,
};
}
class _FailureBanner extends StatelessWidget {
const _FailureBanner({
required this.failure,
required this.onRetry,
super.key,
});
final AuthFailure failure;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final retryable = failure is NetworkFailure || failure is Unexpected;
return AppBanner(
type: AppBannerType.error,
typeLabel: l10n.authBannerTypeError,
message: authFailureMessage(l10n, failure),
actionLabel: retryable && onRetry != null ? l10n.authActionRetry : null,
onAction: retryable ? onRetry : null,
);
}
}

View file

@ -0,0 +1,115 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
part 'register_state.freezed.dart';
/// Form fields of the registration, in reading order.
enum RegisterField {
email,
password,
displayName,
ageConfirmation,
termsAcceptance,
}
enum RegisterStatus { editing, submitting }
/// Values, visibility of errors and submit state of the registration form.
///
/// Errors are derived from the values by the domain rules. Which of them are
/// shown is decided by [visibleFields] (blur, typing in the password field,
/// submit attempt).
@freezed
abstract class RegisterState with _$RegisterState {
const factory RegisterState({
@Default('') String email,
@Default('') String password,
@Default('') String displayName,
@Default(false) bool ageConfirmed,
@Default(false) bool termsAccepted,
@Default(<RegisterField>{}) Set<RegisterField> visibleFields,
@Default(RegisterStatus.editing) RegisterStatus status,
/// Loaded lazily with the first use of the password field.
CommonPasswordList? commonPasswords,
/// General failure shown as banner.
AuthFailure? bannerFailure,
/// `weak_password` reasons reported by the server, shown at the password.
List<WeakPasswordReason>? serverPasswordReasons,
/// The server rejected the address syntax; shown at the email field.
@Default(false) bool serverEmailInvalid,
/// Field that should receive focus after a failed submit.
RegisterField? focusField,
/// Incremented whenever [focusField] is set, so the screen can react
/// to repeated requests for the same field.
@Default(0) int focusRequest,
}) = _RegisterState;
const RegisterState._();
bool get isSubmitting => status == RegisterStatus.submitting;
EmailError? get emailError => EmailFormat.validate(email);
PasswordError? get passwordError =>
PasswordPolicy.validate(password, common: commonPasswords);
DisplayNameError? get displayNameError =>
DisplayNameRule.validate(displayName);
int get displayNameLength => DisplayNameRule.length(displayName);
bool get ageError => !ageConfirmed;
bool get termsError => !termsAccepted;
bool isVisible(RegisterField field) => visibleFields.contains(field);
/// Local error of [field] (ignoring visibility).
bool hasError(RegisterField field) => switch (field) {
RegisterField.email => emailError != null,
RegisterField.password => password.isEmpty || passwordError != null,
RegisterField.displayName => displayNameError != null,
RegisterField.ageConfirmation => ageError,
RegisterField.termsAcceptance => termsError,
};
RegisterField? get firstInvalidField {
for (final field in RegisterField.values) {
if (hasError(field)) {
return field;
}
}
return null;
}
// Errors as the screen shows them.
EmailError? get shownEmailError =>
isVisible(RegisterField.email) ? emailError : null;
/// Empty password is "required", otherwise the policy error.
bool get shownPasswordRequired =>
isVisible(RegisterField.password) && password.isEmpty;
PasswordError? get shownPasswordError =>
isVisible(RegisterField.password) ? passwordError : null;
DisplayNameError? get shownDisplayNameError =>
isVisible(RegisterField.displayName) ? displayNameError : null;
bool get shownAgeError =>
isVisible(RegisterField.ageConfirmation) && ageError;
bool get shownTermsError =>
isVisible(RegisterField.termsAcceptance) && termsError;
}

View file

@ -0,0 +1,368 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'register_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RegisterState {
String get email; String get password; String get displayName; bool get ageConfirmed; bool get termsAccepted; Set<RegisterField> get visibleFields; RegisterStatus get status;/// Loaded lazily with the first use of the password field.
CommonPasswordList? get commonPasswords;/// General failure shown as banner.
AuthFailure? get bannerFailure;/// `weak_password` reasons reported by the server, shown at the password.
List<WeakPasswordReason>? get serverPasswordReasons;/// The server rejected the address syntax; shown at the email field.
bool get serverEmailInvalid;/// Field that should receive focus after a failed submit.
RegisterField? get focusField;/// Incremented whenever [focusField] is set, so the screen can react
/// to repeated requests for the same field.
int get focusRequest;
/// Create a copy of RegisterState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RegisterStateCopyWith<RegisterState> get copyWith => _$RegisterStateCopyWithImpl<RegisterState>(this as RegisterState, _$identity);
@override
bool operator ==(Object other) {
final _this = this as RegisterState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is RegisterState&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.password, _this.password) || other.password == _this.password)&&(identical(other.displayName, _this.displayName) || other.displayName == _this.displayName)&&(identical(other.ageConfirmed, _this.ageConfirmed) || other.ageConfirmed == _this.ageConfirmed)&&(identical(other.termsAccepted, _this.termsAccepted) || other.termsAccepted == _this.termsAccepted)&&const DeepCollectionEquality().equals(other.visibleFields, _this.visibleFields)&&(identical(other.status, _this.status) || other.status == _this.status)&&(identical(other.commonPasswords, _this.commonPasswords) || other.commonPasswords == _this.commonPasswords)&&(identical(other.bannerFailure, _this.bannerFailure) || other.bannerFailure == _this.bannerFailure)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _this.serverPasswordReasons)&&(identical(other.serverEmailInvalid, _this.serverEmailInvalid) || other.serverEmailInvalid == _this.serverEmailInvalid)&&(identical(other.focusField, _this.focusField) || other.focusField == _this.focusField)&&(identical(other.focusRequest, _this.focusRequest) || other.focusRequest == _this.focusRequest));
}
@override
int get hashCode {
final _this = this as RegisterState;
return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.commonPasswords,_this.bannerFailure,const DeepCollectionEquality().hash(_this.serverPasswordReasons),_this.serverEmailInvalid,_this.focusField,_this.focusRequest);
}
@override
String toString() {
final _this = this as RegisterState;
return 'RegisterState(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, visibleFields: ${_this.visibleFields}, status: ${_this.status}, commonPasswords: ${_this.commonPasswords}, bannerFailure: ${_this.bannerFailure}, serverPasswordReasons: ${_this.serverPasswordReasons}, serverEmailInvalid: ${_this.serverEmailInvalid}, focusField: ${_this.focusField}, focusRequest: ${_this.focusRequest})';
}
}
/// @nodoc
abstract mixin class $RegisterStateCopyWith<$Res> {
factory $RegisterStateCopyWith(RegisterState value, $Res Function(RegisterState) _then) = _$RegisterStateCopyWithImpl;
@useResult
$Res call({
String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest
});
$AuthFailureCopyWith<$Res>? get bannerFailure;
}
/// @nodoc
class _$RegisterStateCopyWithImpl<$Res>
implements $RegisterStateCopyWith<$Res> {
_$RegisterStateCopyWithImpl(this._self, this._then);
final RegisterState _self;
final $Res Function(RegisterState) _then;
/// Create a copy of RegisterState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? visibleFields = null,Object? status = null,Object? commonPasswords = freezed,Object? bannerFailure = freezed,Object? serverPasswordReasons = freezed,Object? serverEmailInvalid = null,Object? focusField = freezed,Object? focusRequest = null,}) {
return _then(RegisterState(
email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable
as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable
as bool,visibleFields: null == visibleFields ? _self.visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable
as Set<RegisterField>,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as RegisterStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable
as CommonPasswordList?,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,serverPasswordReasons: freezed == serverPasswordReasons ? _self.serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable
as List<WeakPasswordReason>?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable
as bool,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable
as RegisterField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of RegisterState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthFailureCopyWith<$Res>? get bannerFailure {
if (_self.bannerFailure == null) {
return null;
}
return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) {
return _then(_self.copyWith(bannerFailure: value));
});
}
}
/// Adds pattern-matching-related methods to [RegisterState].
extension RegisterStatePatterns on RegisterState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RegisterState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RegisterState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RegisterState value) $default,){
final _that = this;
switch (_that) {
case _RegisterState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RegisterState value)? $default,){
final _that = this;
switch (_that) {
case _RegisterState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RegisterState() when $default != null:
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest) $default,) {final _that = this;
switch (_that) {
case _RegisterState():
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest)? $default,) {final _that = this;
switch (_that) {
case _RegisterState() when $default != null:
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _:
return null;
}
}
}
/// @nodoc
class _RegisterState extends RegisterState {
const _RegisterState({this.email = '', this.password = '', this.displayName = '', this.ageConfirmed = false, this.termsAccepted = false, Set<RegisterField> visibleFields = const <RegisterField>{}, this.status = RegisterStatus.editing, this.commonPasswords, this.bannerFailure, List<WeakPasswordReason>? serverPasswordReasons, this.serverEmailInvalid = false, this.focusField, this.focusRequest = 0}): _visibleFields = visibleFields,_serverPasswordReasons = serverPasswordReasons,super._();
@override@JsonKey() final String email;
@override@JsonKey() final String password;
@override@JsonKey() final String displayName;
@override@JsonKey() final bool ageConfirmed;
@override@JsonKey() final bool termsAccepted;
final Set<RegisterField> _visibleFields;
@override@JsonKey() Set<RegisterField> get visibleFields {
if (_visibleFields is EqualUnmodifiableSetView) return _visibleFields;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_visibleFields);
}
@override@JsonKey() final RegisterStatus status;
/// Loaded lazily with the first use of the password field.
@override final CommonPasswordList? commonPasswords;
/// General failure shown as banner.
@override final AuthFailure? bannerFailure;
/// `weak_password` reasons reported by the server, shown at the password.
final List<WeakPasswordReason>? _serverPasswordReasons;
/// `weak_password` reasons reported by the server, shown at the password.
@override List<WeakPasswordReason>? get serverPasswordReasons {
final value = _serverPasswordReasons;
if (value == null) return null;
if (_serverPasswordReasons is EqualUnmodifiableListView) return _serverPasswordReasons;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
/// The server rejected the address syntax; shown at the email field.
@override@JsonKey() final bool serverEmailInvalid;
/// Field that should receive focus after a failed submit.
@override final RegisterField? focusField;
/// Incremented whenever [focusField] is set, so the screen can react
/// to repeated requests for the same field.
@override@JsonKey() final int focusRequest;
/// Create a copy of RegisterState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RegisterStateCopyWith<_RegisterState> get copyWith => __$RegisterStateCopyWithImpl<_RegisterState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RegisterState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.ageConfirmed, ageConfirmed) || other.ageConfirmed == ageConfirmed)&&(identical(other.termsAccepted, termsAccepted) || other.termsAccepted == termsAccepted)&&const DeepCollectionEquality().equals(other.visibleFields, _visibleFields)&&(identical(other.status, status) || other.status == status)&&(identical(other.commonPasswords, commonPasswords) || other.commonPasswords == commonPasswords)&&(identical(other.bannerFailure, bannerFailure) || other.bannerFailure == bannerFailure)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _serverPasswordReasons)&&(identical(other.serverEmailInvalid, serverEmailInvalid) || other.serverEmailInvalid == serverEmailInvalid)&&(identical(other.focusField, focusField) || other.focusField == focusField)&&(identical(other.focusRequest, focusRequest) || other.focusRequest == focusRequest));
}
@override
int get hashCode {
return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,const DeepCollectionEquality().hash(_visibleFields),status,commonPasswords,bannerFailure,const DeepCollectionEquality().hash(_serverPasswordReasons),serverEmailInvalid,focusField,focusRequest);
}
@override
String toString() {
return 'RegisterState(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, visibleFields: $visibleFields, status: $status, commonPasswords: $commonPasswords, bannerFailure: $bannerFailure, serverPasswordReasons: $serverPasswordReasons, serverEmailInvalid: $serverEmailInvalid, focusField: $focusField, focusRequest: $focusRequest)';
}
}
/// @nodoc
abstract mixin class _$RegisterStateCopyWith<$Res> implements $RegisterStateCopyWith<$Res> {
factory _$RegisterStateCopyWith(_RegisterState value, $Res Function(_RegisterState) _then) = __$RegisterStateCopyWithImpl;
@override @useResult
$Res call({
String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set<RegisterField> visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List<WeakPasswordReason>? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest
});
@override $AuthFailureCopyWith<$Res>? get bannerFailure;
}
/// @nodoc
class __$RegisterStateCopyWithImpl<$Res>
implements _$RegisterStateCopyWith<$Res> {
__$RegisterStateCopyWithImpl(this._self, this._then);
final _RegisterState _self;
final $Res Function(_RegisterState) _then;
/// Create a copy of RegisterState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? visibleFields = null,Object? status = null,Object? commonPasswords = freezed,Object? bannerFailure = freezed,Object? serverPasswordReasons = freezed,Object? serverEmailInvalid = null,Object? focusField = freezed,Object? focusRequest = null,}) {
return _then(_RegisterState(
email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable
as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable
as bool,visibleFields: null == visibleFields ? _self._visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable
as Set<RegisterField>,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as RegisterStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable
as CommonPasswordList?,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,serverPasswordReasons: freezed == serverPasswordReasons ? _self._serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable
as List<WeakPasswordReason>?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable
as bool,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable
as RegisterField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of RegisterState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthFailureCopyWith<$Res>? get bannerFailure {
if (_self.bannerFailure == null) {
return null;
}
return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) {
return _then(_self.copyWith(bannerFailure: value));
});
}
}
// dart format on

View file

@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
/// Screen title as heading for screen readers (design: `headlineSmall`).
class AuthScreenTitle extends StatelessWidget {
const AuthScreenTitle(this.title, {super.key});
final String title;
@override
Widget build(BuildContext context) {
return Semantics(
header: true,
child: Text(title, style: Theme.of(context).textTheme.headlineSmall),
);
}
}

View file

@ -4,5 +4,64 @@
"homePlaceholderMessage": "Willkommen bei Shuffle. Der Brettspielkatalog folgt in Kürze.",
"notFoundTitle": "Seite nicht gefunden",
"notFoundMessage": "Diese Seite existiert nicht oder wurde verschoben.",
"notFoundBackHome": "Zurück zum Start"
"notFoundBackHome": "Zurück zum Start",
"authErrorInvalidCredentials": "E-Mail-Adresse oder Passwort ist nicht korrekt.",
"authErrorEmailNotConfirmed": "Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt.",
"authActionResendConfirmation": "Bestätigungs-E-Mail erneut senden",
"authErrorRateLimited": "Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.",
"authErrorWeakPasswordLength": "Das Passwort ist zu kurz.",
"authErrorWeakPasswordCharacters": "Das Passwort ist zu einfach. Wähle ein anderes.",
"authErrorWeakPasswordPwned": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.",
"authErrorSamePassword": "Wähle ein anderes als dein bisheriges Passwort.",
"authErrorLinkInvalidOrExpired": "Der Link ist abgelaufen oder wurde schon benutzt.",
"authActionRequestNewLink": "Neuen Link anfordern",
"authErrorEmailInvalid": "Gib eine gültige E-Mail-Adresse ein.",
"authErrorRegistrationRejected": "Registrierung nicht möglich. Bitte prüfe deine Angaben.",
"authErrorNetwork": "Keine Verbindung. Für diesen Schritt brauchst du Internet.",
"authErrorUnexpected": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"authActionRetry": "Erneut versuchen",
"authPasswordTooShort": "Mindestens 10 Zeichen – {missing, plural, =1{es fehlt noch 1 Zeichen} other{es fehlen noch {missing} Zeichen}}.",
"authPasswordCommon": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.",
"authPasswordRule": "Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.",
"authPasswordRequired": "Gib ein Passwort ein.",
"authDisplayNameEmpty": "Gib einen Anzeigenamen ein.",
"authDisplayNameTooLong": "Der Anzeigename darf höchstens {max} Zeichen lang sein.",
"authDisplayNameInvalid": "Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen.",
"authDisplayNameCounter": "{count}/{max}",
"authAgeNotConfirmed": "Shuffle ist erst ab 16 Jahren nutzbar.",
"authTermsNotAccepted": "Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.",
"authFieldEmailLabel": "E-Mail-Adresse",
"authFieldPasswordLabel": "Passwort",
"authFieldDisplayNameLabel": "Anzeigename",
"authPasswordShow": "Passwort anzeigen",
"authPasswordHide": "Passwort verbergen",
"authCheckboxAgeLabel": "Ich bin mindestens 16 Jahre alt",
"authCheckboxTermsPrefix": "Ich habe die",
"authCheckboxTermsPrivacyLink": "Datenschutzerklärung",
"authCheckboxTermsMiddle": "gelesen und stimme den",
"authCheckboxTermsTermsLink": "Nutzungsbedingungen",
"authCheckboxTermsSuffix": "zu",
"authBannerTypeError": "Fehler",
"authBannerTypeInfo": "Hinweis",
"authSubmitting": "Bitte warten …",
"authRegisterTitle": "Konto erstellen",
"authRegisterSubmit": "Konto erstellen",
"authRegisterHaveAccount": "Schon ein Konto? Anmelden",
"authCheckEmailTitle": "Prüfe dein Postfach",
"authCheckEmailBody": "Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.",
"authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.",
"authCheckEmailValidity": "Der Link ist {duration} gültig.",
"authCheckEmailValidityOneHour": "1 Stunde",
"authCheckEmailResend": "E-Mail erneut senden",
"authCheckEmailResent": "Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt.",
"authBackToLogin": "Zurück zur Anmeldung",
"authConfirmTitle": "E-Mail-Adresse bestätigen",
"authConfirmBody": "Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen.",
"authConfirmSubmit": "E-Mail-Adresse bestätigen",
"authConfirmSuccess": "E-Mail bestätigt.",
"authConfirmSuccessMobileWeb": "Du kannst dich jetzt auch in der App anmelden.",
"authConfirmContinue": "Weiter",
"authRecoveryTitle": "Neues Passwort festlegen",
"authRecoveryBody": "Ein Klick genügt, um ein neues Passwort festzulegen.",
"authRecoverySubmit": "Weiter zum neuen Passwort"
}

View file

@ -19,5 +19,95 @@
"notFoundBackHome": "Back to start",
"@notFoundBackHome": {
"description": "Button on the 404 page that navigates to the start page"
}
},
"authErrorInvalidCredentials": "Email address or password is incorrect.",
"authErrorEmailNotConfirmed": "Please confirm your email address first. We sent you an email for this.",
"authActionResendConfirmation": "Resend confirmation email",
"authErrorRateLimited": "Too many attempts. Please wait a few minutes and try again.",
"authErrorWeakPasswordLength": "The password is too short.",
"authErrorWeakPasswordCharacters": "The password is too simple. Choose a different one.",
"authErrorWeakPasswordPwned": "This password is very common and easy to guess. Choose a different one.",
"authErrorSamePassword": "Choose a password different from your current one.",
"authErrorLinkInvalidOrExpired": "The link has expired or has already been used.",
"authActionRequestNewLink": "Request a new link",
"authErrorEmailInvalid": "Enter a valid email address.",
"authErrorRegistrationRejected": "Registration is not possible. Please check your details.",
"authErrorNetwork": "No connection. You need internet access for this step.",
"authErrorUnexpected": "Something went wrong. Please try again.",
"authActionRetry": "Try again",
"authPasswordTooShort": "At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.",
"@authPasswordTooShort": {
"placeholders": {
"missing": {
"type": "int"
}
}
},
"authPasswordCommon": "This password is very common and easy to guess. Choose a different one.",
"authPasswordRule": "At least 10 characters. Not a very common password.",
"authPasswordRequired": "Enter a password.",
"authDisplayNameEmpty": "Enter a display name.",
"authDisplayNameTooLong": "The display name can be at most {max} characters long.",
"@authDisplayNameTooLong": {
"placeholders": {
"max": {
"type": "int"
}
}
},
"authDisplayNameInvalid": "This display name is not allowed. Use visible characters.",
"authDisplayNameCounter": "{count}/{max}",
"@authDisplayNameCounter": {
"placeholders": {
"count": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"authAgeNotConfirmed": "Shuffle can only be used from age 16.",
"authTermsNotAccepted": "Please agree to the privacy policy and the terms of use.",
"authFieldEmailLabel": "Email address",
"authFieldPasswordLabel": "Password",
"authFieldDisplayNameLabel": "Display name",
"authPasswordShow": "Show password",
"authPasswordHide": "Hide password",
"authCheckboxAgeLabel": "I am at least 16 years old",
"authCheckboxTermsPrefix": "I have read the",
"authCheckboxTermsPrivacyLink": "privacy policy",
"authCheckboxTermsMiddle": "and agree to the",
"authCheckboxTermsTermsLink": "terms of use",
"authCheckboxTermsSuffix": "",
"authBannerTypeError": "Error",
"authBannerTypeInfo": "Note",
"authSubmitting": "Please wait …",
"authRegisterTitle": "Create account",
"authRegisterSubmit": "Create account",
"authRegisterHaveAccount": "Already have an account? Sign in",
"authCheckEmailTitle": "Check your inbox",
"authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.",
"authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.",
"authCheckEmailValidity": "The link is valid for {duration}.",
"@authCheckEmailValidity": {
"placeholders": {
"duration": {
"type": "String"
}
}
},
"authCheckEmailValidityOneHour": "1 hour",
"authCheckEmailResend": "Resend email",
"authCheckEmailResent": "We resent the email, if there is something to send for this address.",
"authBackToLogin": "Back to sign in",
"authConfirmTitle": "Confirm email address",
"authConfirmBody": "One click is enough to confirm your email address.",
"authConfirmSubmit": "Confirm email address",
"authConfirmSuccess": "Email confirmed.",
"authConfirmSuccessMobileWeb": "You can now also sign in in the app.",
"authConfirmContinue": "Continue",
"authRecoveryTitle": "Set a new password",
"authRecoveryBody": "One click is enough to set a new password.",
"authRecoverySubmit": "Continue to new password"
}

View file

@ -127,6 +127,360 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Back to start'**
String get notFoundBackHome;
/// No description provided for @authErrorInvalidCredentials.
///
/// In en, this message translates to:
/// **'Email address or password is incorrect.'**
String get authErrorInvalidCredentials;
/// No description provided for @authErrorEmailNotConfirmed.
///
/// In en, this message translates to:
/// **'Please confirm your email address first. We sent you an email for this.'**
String get authErrorEmailNotConfirmed;
/// No description provided for @authActionResendConfirmation.
///
/// In en, this message translates to:
/// **'Resend confirmation email'**
String get authActionResendConfirmation;
/// No description provided for @authErrorRateLimited.
///
/// In en, this message translates to:
/// **'Too many attempts. Please wait a few minutes and try again.'**
String get authErrorRateLimited;
/// No description provided for @authErrorWeakPasswordLength.
///
/// In en, this message translates to:
/// **'The password is too short.'**
String get authErrorWeakPasswordLength;
/// No description provided for @authErrorWeakPasswordCharacters.
///
/// In en, this message translates to:
/// **'The password is too simple. Choose a different one.'**
String get authErrorWeakPasswordCharacters;
/// No description provided for @authErrorWeakPasswordPwned.
///
/// In en, this message translates to:
/// **'This password is very common and easy to guess. Choose a different one.'**
String get authErrorWeakPasswordPwned;
/// No description provided for @authErrorSamePassword.
///
/// In en, this message translates to:
/// **'Choose a password different from your current one.'**
String get authErrorSamePassword;
/// No description provided for @authErrorLinkInvalidOrExpired.
///
/// In en, this message translates to:
/// **'The link has expired or has already been used.'**
String get authErrorLinkInvalidOrExpired;
/// No description provided for @authActionRequestNewLink.
///
/// In en, this message translates to:
/// **'Request a new link'**
String get authActionRequestNewLink;
/// No description provided for @authErrorEmailInvalid.
///
/// In en, this message translates to:
/// **'Enter a valid email address.'**
String get authErrorEmailInvalid;
/// No description provided for @authErrorRegistrationRejected.
///
/// In en, this message translates to:
/// **'Registration is not possible. Please check your details.'**
String get authErrorRegistrationRejected;
/// No description provided for @authErrorNetwork.
///
/// In en, this message translates to:
/// **'No connection. You need internet access for this step.'**
String get authErrorNetwork;
/// No description provided for @authErrorUnexpected.
///
/// In en, this message translates to:
/// **'Something went wrong. Please try again.'**
String get authErrorUnexpected;
/// No description provided for @authActionRetry.
///
/// In en, this message translates to:
/// **'Try again'**
String get authActionRetry;
/// No description provided for @authPasswordTooShort.
///
/// In en, this message translates to:
/// **'At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.'**
String authPasswordTooShort(int missing);
/// No description provided for @authPasswordCommon.
///
/// In en, this message translates to:
/// **'This password is very common and easy to guess. Choose a different one.'**
String get authPasswordCommon;
/// No description provided for @authPasswordRule.
///
/// In en, this message translates to:
/// **'At least 10 characters. Not a very common password.'**
String get authPasswordRule;
/// No description provided for @authPasswordRequired.
///
/// In en, this message translates to:
/// **'Enter a password.'**
String get authPasswordRequired;
/// No description provided for @authDisplayNameEmpty.
///
/// In en, this message translates to:
/// **'Enter a display name.'**
String get authDisplayNameEmpty;
/// No description provided for @authDisplayNameTooLong.
///
/// In en, this message translates to:
/// **'The display name can be at most {max} characters long.'**
String authDisplayNameTooLong(int max);
/// No description provided for @authDisplayNameInvalid.
///
/// In en, this message translates to:
/// **'This display name is not allowed. Use visible characters.'**
String get authDisplayNameInvalid;
/// No description provided for @authDisplayNameCounter.
///
/// In en, this message translates to:
/// **'{count}/{max}'**
String authDisplayNameCounter(int count, int max);
/// No description provided for @authAgeNotConfirmed.
///
/// In en, this message translates to:
/// **'Shuffle can only be used from age 16.'**
String get authAgeNotConfirmed;
/// No description provided for @authTermsNotAccepted.
///
/// In en, this message translates to:
/// **'Please agree to the privacy policy and the terms of use.'**
String get authTermsNotAccepted;
/// No description provided for @authFieldEmailLabel.
///
/// In en, this message translates to:
/// **'Email address'**
String get authFieldEmailLabel;
/// No description provided for @authFieldPasswordLabel.
///
/// In en, this message translates to:
/// **'Password'**
String get authFieldPasswordLabel;
/// No description provided for @authFieldDisplayNameLabel.
///
/// In en, this message translates to:
/// **'Display name'**
String get authFieldDisplayNameLabel;
/// No description provided for @authPasswordShow.
///
/// In en, this message translates to:
/// **'Show password'**
String get authPasswordShow;
/// No description provided for @authPasswordHide.
///
/// In en, this message translates to:
/// **'Hide password'**
String get authPasswordHide;
/// No description provided for @authCheckboxAgeLabel.
///
/// In en, this message translates to:
/// **'I am at least 16 years old'**
String get authCheckboxAgeLabel;
/// No description provided for @authCheckboxTermsPrefix.
///
/// In en, this message translates to:
/// **'I have read the'**
String get authCheckboxTermsPrefix;
/// No description provided for @authCheckboxTermsPrivacyLink.
///
/// In en, this message translates to:
/// **'privacy policy'**
String get authCheckboxTermsPrivacyLink;
/// No description provided for @authCheckboxTermsMiddle.
///
/// In en, this message translates to:
/// **'and agree to the'**
String get authCheckboxTermsMiddle;
/// No description provided for @authCheckboxTermsTermsLink.
///
/// In en, this message translates to:
/// **'terms of use'**
String get authCheckboxTermsTermsLink;
/// No description provided for @authCheckboxTermsSuffix.
///
/// In en, this message translates to:
/// **''**
String get authCheckboxTermsSuffix;
/// No description provided for @authBannerTypeError.
///
/// In en, this message translates to:
/// **'Error'**
String get authBannerTypeError;
/// No description provided for @authBannerTypeInfo.
///
/// In en, this message translates to:
/// **'Note'**
String get authBannerTypeInfo;
/// No description provided for @authSubmitting.
///
/// In en, this message translates to:
/// **'Please wait …'**
String get authSubmitting;
/// No description provided for @authRegisterTitle.
///
/// In en, this message translates to:
/// **'Create account'**
String get authRegisterTitle;
/// No description provided for @authRegisterSubmit.
///
/// In en, this message translates to:
/// **'Create account'**
String get authRegisterSubmit;
/// No description provided for @authRegisterHaveAccount.
///
/// In en, this message translates to:
/// **'Already have an account? Sign in'**
String get authRegisterHaveAccount;
/// No description provided for @authCheckEmailTitle.
///
/// In en, this message translates to:
/// **'Check your inbox'**
String get authCheckEmailTitle;
/// No description provided for @authCheckEmailBody.
///
/// In en, this message translates to:
/// **'We sent you an email if the address is not registered yet. Follow the link in it to continue.'**
String get authCheckEmailBody;
/// No description provided for @authCheckEmailDelayHint.
///
/// In en, this message translates to:
/// **'The email can take a few minutes. Also check your spam folder.'**
String get authCheckEmailDelayHint;
/// No description provided for @authCheckEmailValidity.
///
/// In en, this message translates to:
/// **'The link is valid for {duration}.'**
String authCheckEmailValidity(String duration);
/// No description provided for @authCheckEmailValidityOneHour.
///
/// In en, this message translates to:
/// **'1 hour'**
String get authCheckEmailValidityOneHour;
/// No description provided for @authCheckEmailResend.
///
/// In en, this message translates to:
/// **'Resend email'**
String get authCheckEmailResend;
/// No description provided for @authCheckEmailResent.
///
/// In en, this message translates to:
/// **'We resent the email, if there is something to send for this address.'**
String get authCheckEmailResent;
/// No description provided for @authBackToLogin.
///
/// In en, this message translates to:
/// **'Back to sign in'**
String get authBackToLogin;
/// No description provided for @authConfirmTitle.
///
/// In en, this message translates to:
/// **'Confirm email address'**
String get authConfirmTitle;
/// No description provided for @authConfirmBody.
///
/// In en, this message translates to:
/// **'One click is enough to confirm your email address.'**
String get authConfirmBody;
/// No description provided for @authConfirmSubmit.
///
/// In en, this message translates to:
/// **'Confirm email address'**
String get authConfirmSubmit;
/// No description provided for @authConfirmSuccess.
///
/// In en, this message translates to:
/// **'Email confirmed.'**
String get authConfirmSuccess;
/// No description provided for @authConfirmSuccessMobileWeb.
///
/// In en, this message translates to:
/// **'You can now also sign in in the app.'**
String get authConfirmSuccessMobileWeb;
/// No description provided for @authConfirmContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get authConfirmContinue;
/// No description provided for @authRecoveryTitle.
///
/// In en, this message translates to:
/// **'Set a new password'**
String get authRecoveryTitle;
/// No description provided for @authRecoveryBody.
///
/// In en, this message translates to:
/// **'One click is enough to set a new password.'**
String get authRecoveryBody;
/// No description provided for @authRecoverySubmit.
///
/// In en, this message translates to:
/// **'Continue to new password'**
String get authRecoverySubmit;
}
class _AppLocalizationsDelegate

View file

@ -25,4 +25,216 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get notFoundBackHome => 'Zurück zum Start';
@override
String get authErrorInvalidCredentials =>
'E-Mail-Adresse oder Passwort ist nicht korrekt.';
@override
String get authErrorEmailNotConfirmed =>
'Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt.';
@override
String get authActionResendConfirmation =>
'Bestätigungs-E-Mail erneut senden';
@override
String get authErrorRateLimited =>
'Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.';
@override
String get authErrorWeakPasswordLength => 'Das Passwort ist zu kurz.';
@override
String get authErrorWeakPasswordCharacters =>
'Das Passwort ist zu einfach. Wähle ein anderes.';
@override
String get authErrorWeakPasswordPwned =>
'Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.';
@override
String get authErrorSamePassword =>
'Wähle ein anderes als dein bisheriges Passwort.';
@override
String get authErrorLinkInvalidOrExpired =>
'Der Link ist abgelaufen oder wurde schon benutzt.';
@override
String get authActionRequestNewLink => 'Neuen Link anfordern';
@override
String get authErrorEmailInvalid => 'Gib eine gültige E-Mail-Adresse ein.';
@override
String get authErrorRegistrationRejected =>
'Registrierung nicht möglich. Bitte prüfe deine Angaben.';
@override
String get authErrorNetwork =>
'Keine Verbindung. Für diesen Schritt brauchst du Internet.';
@override
String get authErrorUnexpected =>
'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
@override
String get authActionRetry => 'Erneut versuchen';
@override
String authPasswordTooShort(int missing) {
String _temp0 = intl.Intl.pluralLogic(
missing,
locale: localeName,
other: 'es fehlen noch $missing Zeichen',
one: 'es fehlt noch 1 Zeichen',
);
return 'Mindestens 10 Zeichen – $_temp0.';
}
@override
String get authPasswordCommon =>
'Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.';
@override
String get authPasswordRule =>
'Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.';
@override
String get authPasswordRequired => 'Gib ein Passwort ein.';
@override
String get authDisplayNameEmpty => 'Gib einen Anzeigenamen ein.';
@override
String authDisplayNameTooLong(int max) {
return 'Der Anzeigename darf höchstens $max Zeichen lang sein.';
}
@override
String get authDisplayNameInvalid =>
'Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen.';
@override
String authDisplayNameCounter(int count, int max) {
return '$count/$max';
}
@override
String get authAgeNotConfirmed => 'Shuffle ist erst ab 16 Jahren nutzbar.';
@override
String get authTermsNotAccepted =>
'Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.';
@override
String get authFieldEmailLabel => 'E-Mail-Adresse';
@override
String get authFieldPasswordLabel => 'Passwort';
@override
String get authFieldDisplayNameLabel => 'Anzeigename';
@override
String get authPasswordShow => 'Passwort anzeigen';
@override
String get authPasswordHide => 'Passwort verbergen';
@override
String get authCheckboxAgeLabel => 'Ich bin mindestens 16 Jahre alt';
@override
String get authCheckboxTermsPrefix => 'Ich habe die';
@override
String get authCheckboxTermsPrivacyLink => 'Datenschutzerklärung';
@override
String get authCheckboxTermsMiddle => 'gelesen und stimme den';
@override
String get authCheckboxTermsTermsLink => 'Nutzungsbedingungen';
@override
String get authCheckboxTermsSuffix => 'zu';
@override
String get authBannerTypeError => 'Fehler';
@override
String get authBannerTypeInfo => 'Hinweis';
@override
String get authSubmitting => 'Bitte warten …';
@override
String get authRegisterTitle => 'Konto erstellen';
@override
String get authRegisterSubmit => 'Konto erstellen';
@override
String get authRegisterHaveAccount => 'Schon ein Konto? Anmelden';
@override
String get authCheckEmailTitle => 'Prüfe dein Postfach';
@override
String get authCheckEmailBody =>
'Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.';
@override
String get authCheckEmailDelayHint =>
'Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.';
@override
String authCheckEmailValidity(String duration) {
return 'Der Link ist $duration gültig.';
}
@override
String get authCheckEmailValidityOneHour => '1 Stunde';
@override
String get authCheckEmailResend => 'E-Mail erneut senden';
@override
String get authCheckEmailResent =>
'Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt.';
@override
String get authBackToLogin => 'Zurück zur Anmeldung';
@override
String get authConfirmTitle => 'E-Mail-Adresse bestätigen';
@override
String get authConfirmBody =>
'Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen.';
@override
String get authConfirmSubmit => 'E-Mail-Adresse bestätigen';
@override
String get authConfirmSuccess => 'E-Mail bestätigt.';
@override
String get authConfirmSuccessMobileWeb =>
'Du kannst dich jetzt auch in der App anmelden.';
@override
String get authConfirmContinue => 'Weiter';
@override
String get authRecoveryTitle => 'Neues Passwort festlegen';
@override
String get authRecoveryBody =>
'Ein Klick genügt, um ein neues Passwort festzulegen.';
@override
String get authRecoverySubmit => 'Weiter zum neuen Passwort';
}

View file

@ -24,4 +24,213 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get notFoundBackHome => 'Back to start';
@override
String get authErrorInvalidCredentials =>
'Email address or password is incorrect.';
@override
String get authErrorEmailNotConfirmed =>
'Please confirm your email address first. We sent you an email for this.';
@override
String get authActionResendConfirmation => 'Resend confirmation email';
@override
String get authErrorRateLimited =>
'Too many attempts. Please wait a few minutes and try again.';
@override
String get authErrorWeakPasswordLength => 'The password is too short.';
@override
String get authErrorWeakPasswordCharacters =>
'The password is too simple. Choose a different one.';
@override
String get authErrorWeakPasswordPwned =>
'This password is very common and easy to guess. Choose a different one.';
@override
String get authErrorSamePassword =>
'Choose a password different from your current one.';
@override
String get authErrorLinkInvalidOrExpired =>
'The link has expired or has already been used.';
@override
String get authActionRequestNewLink => 'Request a new link';
@override
String get authErrorEmailInvalid => 'Enter a valid email address.';
@override
String get authErrorRegistrationRejected =>
'Registration is not possible. Please check your details.';
@override
String get authErrorNetwork =>
'No connection. You need internet access for this step.';
@override
String get authErrorUnexpected => 'Something went wrong. Please try again.';
@override
String get authActionRetry => 'Try again';
@override
String authPasswordTooShort(int missing) {
String _temp0 = intl.Intl.pluralLogic(
missing,
locale: localeName,
other: '$missing more characters needed',
one: '1 more character needed',
);
return 'At least 10 characters – $_temp0.';
}
@override
String get authPasswordCommon =>
'This password is very common and easy to guess. Choose a different one.';
@override
String get authPasswordRule =>
'At least 10 characters. Not a very common password.';
@override
String get authPasswordRequired => 'Enter a password.';
@override
String get authDisplayNameEmpty => 'Enter a display name.';
@override
String authDisplayNameTooLong(int max) {
return 'The display name can be at most $max characters long.';
}
@override
String get authDisplayNameInvalid =>
'This display name is not allowed. Use visible characters.';
@override
String authDisplayNameCounter(int count, int max) {
return '$count/$max';
}
@override
String get authAgeNotConfirmed => 'Shuffle can only be used from age 16.';
@override
String get authTermsNotAccepted =>
'Please agree to the privacy policy and the terms of use.';
@override
String get authFieldEmailLabel => 'Email address';
@override
String get authFieldPasswordLabel => 'Password';
@override
String get authFieldDisplayNameLabel => 'Display name';
@override
String get authPasswordShow => 'Show password';
@override
String get authPasswordHide => 'Hide password';
@override
String get authCheckboxAgeLabel => 'I am at least 16 years old';
@override
String get authCheckboxTermsPrefix => 'I have read the';
@override
String get authCheckboxTermsPrivacyLink => 'privacy policy';
@override
String get authCheckboxTermsMiddle => 'and agree to the';
@override
String get authCheckboxTermsTermsLink => 'terms of use';
@override
String get authCheckboxTermsSuffix => '';
@override
String get authBannerTypeError => 'Error';
@override
String get authBannerTypeInfo => 'Note';
@override
String get authSubmitting => 'Please wait …';
@override
String get authRegisterTitle => 'Create account';
@override
String get authRegisterSubmit => 'Create account';
@override
String get authRegisterHaveAccount => 'Already have an account? Sign in';
@override
String get authCheckEmailTitle => 'Check your inbox';
@override
String get authCheckEmailBody =>
'We sent you an email if the address is not registered yet. Follow the link in it to continue.';
@override
String get authCheckEmailDelayHint =>
'The email can take a few minutes. Also check your spam folder.';
@override
String authCheckEmailValidity(String duration) {
return 'The link is valid for $duration.';
}
@override
String get authCheckEmailValidityOneHour => '1 hour';
@override
String get authCheckEmailResend => 'Resend email';
@override
String get authCheckEmailResent =>
'We resent the email, if there is something to send for this address.';
@override
String get authBackToLogin => 'Back to sign in';
@override
String get authConfirmTitle => 'Confirm email address';
@override
String get authConfirmBody =>
'One click is enough to confirm your email address.';
@override
String get authConfirmSubmit => 'Confirm email address';
@override
String get authConfirmSuccess => 'Email confirmed.';
@override
String get authConfirmSuccessMobileWeb =>
'You can now also sign in in the app.';
@override
String get authConfirmContinue => 'Continue';
@override
String get authRecoveryTitle => 'Set a new password';
@override
String get authRecoveryBody => 'One click is enough to set a new password.';
@override
String get authRecoverySubmit => 'Continue to new password';
}

View file

@ -3,13 +3,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shuffle/app.dart';
import 'package:shuffle/core/config/app_config.dart';
import 'package:shuffle/core/error/global_error_handler.dart';
import 'package:shuffle/core/licenses/third_party_licenses.dart';
import 'package:shuffle/core/logging/app_logger.dart';
import 'package:shuffle/core/routing/url_strategy.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
AppLogger.configure();
registerThirdPartyLicenses();
installGlobalErrorHandlers();
configurePathUrlStrategy();
@ -19,5 +22,5 @@ Future<void> main() async {
publishableKey: config.supabaseAnonKey,
);
runApp(const ProviderScope(child: ShuffleApp()));
runApp(ProviderScope(overrides: authOverrides(), child: const ShuffleApp()));
}

View file

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

View file

@ -23,6 +23,8 @@ dependencies:
json_annotation: 4.12.0
logging: 1.3.0
intl: 0.20.3
http: 1.6.0
url_launcher: 6.3.3
dev_dependencies:
flutter_test:
@ -37,3 +39,6 @@ dev_dependencies:
flutter:
uses-material-design: true
generate: true
assets:
- assets/security/common-passwords.txt
- THIRD_PARTY.md

View file

@ -0,0 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/config/auth_config.dart';
void main() {
test('KTO-01 Kriterium 8, AUTH-CFG-07: link validity is 3600 s', () {
// The text "1 Stunde / 1 hour" (authCheckEmailValidityOneHour) is only
// correct as long as this holds.
expect(AuthConfig.linkValidity.inSeconds, 3600);
});
}

View file

@ -0,0 +1,26 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/licenses/third_party_licenses.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('the SecLists MIT license shows up in the LicenseRegistry', () async {
registerThirdPartyLicenses();
final entries = await LicenseRegistry.licenses.toList();
final entry = entries.firstWhere(
(e) => e.packages.contains(thirdPartyLicensePackage),
);
final text = entry.paragraphs.map((p) => p.text).join('\n');
expect(text, contains('MIT License'));
expect(text, contains('Copyright (c) 2018 Daniel Miessler'));
expect(text, contains('SecLists'));
});
test('the license asset is declared in the bundle', () async {
final text = await rootBundle.loadString(thirdPartyAsset);
expect(text, contains('Permission is hereby granted'));
});
}

View file

@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/platform/url_opener.dart';
void main() {
group('guardedUrlOpener', () {
test('opens http and https URLs with a host', () async {
final launched = <Uri>[];
final open = guardedUrlOpener((uri) async {
launched.add(uri);
return true;
});
expect(await open(Uri.parse('https://shuffle.example/privacy')), isTrue);
expect(await open(Uri.parse('http://shuffle.example/terms')), isTrue);
expect(launched, hasLength(2));
});
for (final url in [
'javascript:alert(1)',
'file:///etc/passwd',
'intent://scan/#Intent;scheme=zxing;end',
'mailto:a@b.de',
'https:///no-host',
'/relative/path',
'data:text/html,hi',
]) {
test('refuses $url', () async {
var launches = 0;
final open = guardedUrlOpener((uri) async {
launches++;
return true;
});
expect(await open(Uri.parse(url)), isFalse);
expect(launches, 0);
});
}
test('returns false when launching throws', () async {
final open = guardedUrlOpener((uri) async => throw StateError('x'));
expect(await open(Uri.parse('https://shuffle.example')), isFalse);
});
});
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/routing/volatile_extra_codec.dart';
void main() {
test('extra is not serialized (nothing lands in the browser history)', () {
const codec = VolatileExtraCodec();
expect(codec.encode('anna@example.com'), isNull);
expect(codec.decode('anna@example.com'), isNull);
});
testWidgets('go_router history state does not contain the extra', (
tester,
) async {
Object? seen;
final router = GoRouter(
extraCodec: const VolatileExtraCodec(),
initialLocation: '/a',
routes: [
GoRoute(path: '/a', builder: (_, _) => const SizedBox()),
GoRoute(
path: '/b',
builder: (_, state) {
seen = state.extra;
return const SizedBox();
},
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
router.go('/b', extra: 'anna@example.com');
await tester.pumpAndSettle();
expect(seen, 'anna@example.com', reason: 'extra works in memory');
final info = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
);
expect(info?.state.toString(), isNot(contains('anna@example.com')));
});
}

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_banner.dart';
import 'widget_harness.dart';
void main() {
group('AppBanner', () {
testWidgets('shows message and type icon', (tester) async {
await pumpWidgetUnderTest(
tester,
const AppBanner(
type: AppBannerType.error,
typeLabel: 'Error',
message: 'Something failed',
),
);
expect(find.text('Something failed'), findsOneWidget);
expect(find.byIcon(Icons.error_outline), findsOneWidget);
await pumpWidgetUnderTest(
tester,
const AppBanner(
type: AppBannerType.info,
typeLabel: 'Note',
message: 'FYI',
),
);
expect(find.byIcon(Icons.info_outline), findsOneWidget);
});
testWidgets('is a live region with type prefix', (tester) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
const AppBanner(
type: AppBannerType.error,
typeLabel: 'Error',
message: 'Something failed',
),
);
expect(
tester.getSemantics(find.bySemanticsLabel('Error: Something failed')),
matchesSemantics(label: 'Error: Something failed', isLiveRegion: true),
);
handle.dispose();
});
testWidgets('action calls back and is at least 48 dp', (tester) async {
var calls = 0;
await pumpWidgetUnderTest(
tester,
AppBanner(
type: AppBannerType.info,
typeLabel: 'Note',
message: 'Confirm first',
actionLabel: 'Resend',
onAction: () => calls++,
),
);
await tester.tap(find.text('Resend'));
expect(calls, 1);
expect(
tester.getSize(find.byType(TextButton)).height,
greaterThanOrEqualTo(48),
);
});
for (final mode in themeModes) {
for (final type in AppBannerType.values) {
testWidgets('meets a11y guidelines in $mode as $type', (tester) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
AppBanner(
type: type,
typeLabel: 'Note',
message: 'Confirm your address first',
actionLabel: 'Resend',
onAction: () {},
),
themeMode: mode,
);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
}
testWidgets('does not overflow at 200 % text scale', (tester) async {
await pumpWidgetUnderTest(
tester,
AppBanner(
type: AppBannerType.error,
typeLabel: 'Error',
message: 'A long message that has to wrap onto several lines here',
actionLabel: 'Resend confirmation email',
onAction: () {},
),
textScale: 2,
size: const Size(320, 640),
);
expect(tester.takeException(), isNull);
});
});
}

View file

@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_checkbox_field.dart';
import 'widget_harness.dart';
void main() {
group('AppCheckboxField', () {
testWidgets('tapping the label toggles the value', (tester) async {
bool? received;
await pumpWidgetUnderTest(
tester,
AppCheckboxField(
value: false,
label: 'I am at least 16',
onChanged: (v) => received = v,
),
);
await tester.tap(find.text('I am at least 16'));
expect(received, isTrue);
});
testWidgets('tapping the checkbox toggles the value', (tester) async {
bool? received;
await pumpWidgetUnderTest(
tester,
AppCheckboxField(
value: true,
label: 'Label',
onChanged: (v) => received = v,
),
);
await tester.tap(find.byType(Checkbox));
expect(received, isFalse);
});
testWidgets('does not react when disabled', (tester) async {
var calls = 0;
await pumpWidgetUnderTest(
tester,
AppCheckboxField(
value: false,
label: 'Label',
enabled: false,
onChanged: (_) => calls++,
),
);
await tester.tap(find.text('Label'));
expect(calls, 0);
});
testWidgets('whole row is at least 48 dp high', (tester) async {
await pumpWidgetUnderTest(
tester,
AppCheckboxField(value: false, label: 'Short', onChanged: (_) {}),
);
expect(
tester.getSize(find.byType(InkWell)).height,
greaterThanOrEqualTo(48),
);
});
testWidgets('link tap opens the link and does not toggle', (tester) async {
var toggled = 0;
var opened = 0;
await pumpWidgetUnderTest(
tester,
AppCheckboxField.rich(
value: false,
onChanged: (_) => toggled++,
segments: [
const AppLabelSegment.text('I accept the '),
AppLabelSegment.link('terms', () => opened++),
],
),
);
await tester.tap(find.text('terms'));
expect(opened, 1);
expect(toggled, 0);
await tester.tapAt(
tester.getTopLeft(find.byType(AppCheckboxField)) + const Offset(30, 24),
);
expect(toggled, 1);
});
testWidgets('exposes checked state and full label on the checkbox node', (
tester,
) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
AppCheckboxField.rich(
value: true,
onChanged: (_) {},
segments: [
const AppLabelSegment.text('I accept the '),
AppLabelSegment.link('terms', () {}),
],
),
);
expect(
tester.getSemantics(find.bySemanticsLabel('I accept the terms')),
matchesSemantics(
label: 'I accept the terms',
hasCheckedState: true,
isChecked: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
),
);
handle.dispose();
});
testWidgets('every link is its own semantics node with isLink (web)', (
tester,
) async {
final handle = tester.ensureSemantics();
var privacy = 0;
var terms = 0;
await pumpWidgetUnderTest(
tester,
AppCheckboxField.rich(
value: false,
onChanged: (_) {},
segments: [
const AppLabelSegment.text('I accept the '),
AppLabelSegment.link('terms', () => terms++),
const AppLabelSegment.text(' and the '),
AppLabelSegment.link('privacy policy', () => privacy++),
],
),
);
for (final name in ['terms', 'privacy policy']) {
expect(
tester.getSemantics(find.bySemanticsLabel(name)),
matchesSemantics(label: name, isLink: true, hasTapAction: true),
);
}
tester.semantics.tap(find.semantics.byLabel('terms'));
tester.semantics.tap(find.semantics.byLabel('privacy policy'));
expect(terms, 1);
expect(privacy, 1);
handle.dispose();
});
testWidgets('error shows icon and text as live region, error border', (
tester,
) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
AppCheckboxField(
value: false,
label: 'Label',
errorText: 'Required',
onChanged: (_) {},
),
);
expect(find.text('Required'), findsOneWidget);
expect(find.byIcon(Icons.error_outline), findsOneWidget);
expect(tester.widget<Checkbox>(find.byType(Checkbox)).isError, isTrue);
expect(
tester.getSemantics(find.text('Required')),
matchesSemantics(label: 'Required', isLiveRegion: true),
);
handle.dispose();
});
testWidgets('keyboard focus reaches checkbox and link, space toggles', (
tester,
) async {
bool? received;
var opened = 0;
await pumpWidgetUnderTest(
tester,
AppCheckboxField.rich(
value: false,
onChanged: (v) => received = v,
segments: [
const AppLabelSegment.text('Accept '),
AppLabelSegment.link('terms', () => opened++),
],
),
);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.space);
expect(received, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(opened, 1);
});
for (final mode in themeModes) {
testWidgets('meets a11y guidelines in $mode with error', (tester) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
AppCheckboxField.rich(
value: false,
errorText: 'Required',
onChanged: (_) {},
segments: [
const AppLabelSegment.text('I accept the '),
AppLabelSegment.link('terms', () {}),
],
),
themeMode: mode,
);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('does not overflow at 200 % text scale', (tester) async {
await pumpWidgetUnderTest(
tester,
AppCheckboxField.rich(
value: false,
errorText: 'Please accept the privacy policy and the terms',
onChanged: (_) {},
segments: [
const AppLabelSegment.text('I have read the privacy policy and '),
AppLabelSegment.link('accept the terms', () {}),
],
),
textScale: 2,
size: const Size(320, 640),
);
expect(tester.takeException(), isNull);
});
});
}

View file

@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_form_scaffold.dart';
import '../../helpers/pump_app.dart';
Future<void> _pump(
WidgetTester tester, {
required Size size,
Widget? content,
double? textScale,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
textScale: textScale,
home: AppFormScaffold(
child: content ?? const SizedBox(key: Key('content'), height: 100),
),
);
}
void main() {
group('AppFormScaffold', () {
testWidgets('compact: full width without card', (tester) async {
await _pump(tester, size: const Size(400, 800));
expect(find.byType(Card), findsNothing);
expect(
tester.getSize(find.byKey(const Key('content'))).width,
400 - 2 * AppSpacing.lg,
);
});
testWidgets('just below 600 dp is still compact', (tester) async {
await _pump(tester, size: const Size(599, 800));
expect(find.byType(Card), findsNothing);
});
testWidgets('medium: centered card limited to authCardMaxWidth', (
tester,
) async {
await _pump(tester, size: const Size(700, 900));
expect(find.byType(Card), findsOneWidget);
final card = tester.getRect(find.byType(Card));
expect(card.width, AppBreakpoints.authCardMaxWidth);
expect(card.center.dx, closeTo(350, 0.5));
});
testWidgets('expanded: card stays at authCardMaxWidth', (tester) async {
await _pump(tester, size: const Size(1280, 800));
expect(
tester.getRect(find.byType(Card)).width,
AppBreakpoints.authCardMaxWidth,
);
});
testWidgets('scrolls when the content is taller than the viewport', (
tester,
) async {
await _pump(
tester,
size: const Size(400, 400),
content: const Column(
children: [
SizedBox(height: 900),
SizedBox(key: Key('end'), height: 40),
],
),
);
await tester.scrollUntilVisible(
find.byKey(const Key('end')),
200,
scrollable: find.byType(Scrollable).first,
);
expect(tester.takeException(), isNull);
});
testWidgets('keyboard inset shrinks the body and the form stays '
'scrollable', (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.reset);
await pumpApp(
tester,
home: const AppFormScaffold(
child: Column(
children: [
SizedBox(height: 700),
TextField(key: Key('last')),
],
),
),
);
await tester.scrollUntilVisible(
find.byKey(const Key('last')),
100,
scrollable: find.byType(Scrollable).first,
);
final bottom = tester.getBottomLeft(find.byKey(const Key('last'))).dy;
expect(bottom, lessThanOrEqualTo(500));
});
testWidgets('does not overflow at 200 % text scale on a small phone', (
tester,
) async {
await _pump(
tester,
size: const Size(320, 480),
textScale: 2,
content: const Column(
children: [
Text('A heading that is long enough to wrap around a few lines'),
SizedBox(height: 300),
],
),
);
expect(tester.takeException(), isNull);
});
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
testWidgets('card meets contrast guideline in $mode', (tester) async {
final handle = tester.ensureSemantics();
tester.view.physicalSize = const Size(800, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
themeMode: mode,
home: const AppFormScaffold(child: Text('Sign in')),
);
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
});
}

View file

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_password_field.dart';
import 'widget_harness.dart';
Widget _field({
String? ruleHint,
String? errorText,
bool isNewPassword = false,
TextEditingController? controller,
}) => AppPasswordField(
label: 'Password',
showPasswordLabel: 'Show password',
hidePasswordLabel: 'Hide password',
ruleHint: ruleHint,
errorText: errorText,
isNewPassword: isNewPassword,
controller: controller,
);
bool _obscured(WidgetTester tester) =>
tester.widget<EditableText>(find.byType(EditableText)).obscureText;
void main() {
group('AppPasswordField', () {
testWidgets('is obscured initially and toggles visibility', (tester) async {
await pumpWidgetUnderTest(tester, _field());
expect(_obscured(tester), isTrue);
expect(find.byTooltip('Show password'), findsOneWidget);
await tester.tap(find.byTooltip('Show password'));
await tester.pump();
expect(_obscured(tester), isFalse);
expect(find.byTooltip('Hide password'), findsOneWidget);
await tester.tap(find.byTooltip('Hide password'));
await tester.pump();
expect(_obscured(tester), isTrue);
});
testWidgets('toggle keeps the entered text', (tester) async {
final controller = TextEditingController(text: 'secret-123');
addTearDown(controller.dispose);
await pumpWidgetUnderTest(tester, _field(controller: controller));
await tester.tap(find.byTooltip('Show password'));
await tester.pump();
expect(controller.text, 'secret-123');
});
testWidgets('toggle has a semantics label and a 48 dp target', (
tester,
) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(tester, _field());
expect(
tester.getSemantics(find.byType(IconButton)).tooltip,
'Show password',
);
final size = tester.getSize(find.byType(IconButton));
expect(size.width, greaterThanOrEqualTo(48));
expect(size.height, greaterThanOrEqualTo(48));
handle.dispose();
});
testWidgets('uses password autofill hint by default', (tester) async {
await pumpWidgetUnderTest(tester, _field());
expect(
tester.widget<EditableText>(find.byType(EditableText)).autofillHints,
[AutofillHints.password],
);
});
testWidgets('uses newPassword autofill hint for new passwords', (
tester,
) async {
await pumpWidgetUnderTest(tester, _field(isNewPassword: true));
expect(
tester.widget<EditableText>(find.byType(EditableText)).autofillHints,
[AutofillHints.newPassword],
);
});
testWidgets('disables autocorrect and suggestions', (tester) async {
await pumpWidgetUnderTest(tester, _field());
final editable = tester.widget<EditableText>(find.byType(EditableText));
expect(editable.autocorrect, isFalse);
expect(editable.enableSuggestions, isFalse);
});
testWidgets('shows rule hint, error replaces it', (tester) async {
await pumpWidgetUnderTest(tester, _field(ruleHint: 'At least 10'));
expect(find.text('At least 10'), findsOneWidget);
await pumpWidgetUnderTest(
tester,
_field(ruleHint: 'At least 10', errorText: 'Too short'),
);
expect(find.text('Too short'), findsOneWidget);
expect(find.text('At least 10'), findsNothing);
});
for (final mode in themeModes) {
testWidgets('meets a11y guidelines in $mode', (tester) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
_field(ruleHint: 'At least 10', errorText: 'Too short'),
themeMode: mode,
);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('does not overflow at 200 % text scale', (tester) async {
await pumpWidgetUnderTest(
tester,
_field(ruleHint: 'At least 10 characters, no common passwords'),
textScale: 2,
size: const Size(320, 640),
);
expect(tester.takeException(), isNull);
});
});
}

View file

@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_primary_button.dart';
import 'widget_harness.dart';
void main() {
group('AppPrimaryButton', () {
testWidgets('calls onPressed', (tester) async {
var calls = 0;
await pumpWidgetUnderTest(
tester,
AppPrimaryButton(label: 'Register', onPressed: () => calls++),
);
await tester.tap(find.text('Register'));
expect(calls, 1);
});
testWidgets('is disabled and shows a spinner while loading', (
tester,
) async {
var calls = 0;
await pumpWidgetUnderTest(
tester,
AppPrimaryButton(
label: 'Register',
isLoading: true,
onPressed: () => calls++,
),
settle: false,
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.tap(find.text('Register'), warnIfMissed: false);
expect(calls, 0);
expect(
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
isNull,
);
});
testWidgets('is disabled without onPressed', (tester) async {
await pumpWidgetUnderTest(
tester,
const AppPrimaryButton(label: 'Register', onPressed: null),
);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
isNull,
);
});
testWidgets('semantics: button with label, loading label while loading', (
tester,
) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
AppPrimaryButton(label: 'Register', onPressed: () {}),
);
expect(
tester.getSemantics(find.byType(FilledButton)),
matchesSemantics(
label: 'Register',
isButton: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
),
);
await pumpWidgetUnderTest(
tester,
AppPrimaryButton(
label: 'Register',
loadingLabel: 'Registering',
isLoading: true,
onPressed: () {},
),
settle: false,
);
expect(
tester.getSemantics(find.byType(FilledButton)),
matchesSemantics(
label: 'Registering',
isButton: true,
hasEnabledState: true,
),
);
handle.dispose();
});
testWidgets('is at least 48 dp high and full width by default', (
tester,
) async {
await pumpWidgetUnderTest(
tester,
AppPrimaryButton(label: 'Go', onPressed: () {}),
);
final size = tester.getSize(find.byType(FilledButton));
expect(size.height, greaterThanOrEqualTo(48));
expect(size.width, 400 - 32);
});
for (final mode in themeModes) {
testWidgets('meets a11y guidelines in $mode', (tester) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
AppPrimaryButton(label: 'Register', onPressed: () {}),
themeMode: mode,
);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('does not overflow at 200 % text scale', (tester) async {
await pumpWidgetUnderTest(
tester,
AppPrimaryButton(
label: 'Create account and confirm email address',
isLoading: true,
onPressed: () {},
),
textScale: 2,
size: const Size(320, 640),
settle: false,
);
expect(tester.takeException(), isNull);
});
});
}

View file

@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_text_field.dart';
import 'widget_harness.dart';
void main() {
group('AppTextField', () {
testWidgets('shows label and helper text, error replaces helper', (
tester,
) async {
await pumpWidgetUnderTest(
tester,
const AppTextField(label: 'Email', helperText: 'Helper'),
);
expect(find.text('Email'), findsOneWidget);
expect(find.text('Helper'), findsOneWidget);
await pumpWidgetUnderTest(
tester,
const AppTextField(
label: 'Email',
helperText: 'Helper',
errorText: 'Broken',
),
);
expect(find.text('Broken'), findsOneWidget);
expect(find.text('Helper'), findsNothing);
expect(find.byIcon(Icons.error_outline), findsOneWidget);
});
testWidgets('counterText replaces the default counter', (tester) async {
await pumpWidgetUnderTest(
tester,
const AppTextField(
label: 'Name',
maxLength: 30,
showCounter: true,
counterText: '2/30',
),
);
expect(find.text('2/30'), findsOneWidget);
expect(find.text('0/30'), findsNothing);
});
testWidgets('exposes the label as semantics label of the text field', (
tester,
) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(tester, const AppTextField(label: 'Email'));
expect(
tester.getSemantics(find.byType(TextField)),
matchesSemantics(
label: 'Email',
isTextField: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
hasFocusAction: true,
isFocusable: true,
),
);
handle.dispose();
});
testWidgets('announces the error as live region', (tester) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
const AppTextField(label: 'Email', errorText: 'Broken'),
);
expect(
tester.getSemantics(find.text('Broken')),
matchesSemantics(label: 'Broken', isLiveRegion: true),
);
handle.dispose();
});
testWidgets('liveError false keeps the error visible but silent', (
tester,
) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
const AppTextField(
label: 'Email',
errorText: 'Broken',
liveError: false,
),
);
expect(find.text('Broken'), findsOneWidget);
expect(
tester.getSemantics(find.text('Broken')),
matchesSemantics(label: 'Broken', isLiveRegion: false),
);
handle.dispose();
});
testWidgets('reports changes and keeps input', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
String? last;
await pumpWidgetUnderTest(
tester,
AppTextField(
label: 'Name',
controller: controller,
onChanged: (v) => last = v,
),
);
await tester.enterText(find.byType(TextField), 'abc');
expect(last, 'abc');
expect(controller.text, 'abc');
});
testWidgets('does not block input beyond maxLength, shows counter', (
tester,
) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await pumpWidgetUnderTest(
tester,
AppTextField(
label: 'Name',
controller: controller,
maxLength: 5,
showCounter: true,
),
);
await tester.enterText(find.byType(TextField), 'abcdefg');
await tester.pump();
expect(controller.text, 'abcdefg');
expect(find.text('7/5'), findsOneWidget);
});
testWidgets('hides the counter by default', (tester) async {
await pumpWidgetUnderTest(
tester,
const AppTextField(label: 'Name', maxLength: 5),
);
await tester.enterText(find.byType(TextField), 'ab');
await tester.pump();
expect(find.text('2/5'), findsNothing);
});
testWidgets('applies input formatters', (tester) async {
await pumpWidgetUnderTest(
tester,
AppTextField(
label: 'Digits',
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
);
await tester.enterText(find.byType(TextField), 'a1b2');
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
isNot('a1b2'),
);
});
for (final mode in themeModes) {
testWidgets('meets a11y guidelines in $mode with error', (tester) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
const AppTextField(
label: 'Email',
helperText: 'Helper',
errorText: 'Broken',
),
themeMode: mode,
);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('does not overflow at 200 % text scale with long texts', (
tester,
) async {
await pumpWidgetUnderTest(
tester,
const AppTextField(
label: 'A rather long label that needs to wrap over many lines',
helperText: 'A long helper text that also needs to wrap around',
errorText: 'A long error text that also needs to wrap around lines',
),
textScale: 2,
size: const Size(320, 640),
);
expect(tester.takeException(), isNull);
});
});
}

View file

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/pump_app.dart';
/// Pumps [child] in a scrollable Scaffold with the app theme.
Future<void> pumpWidgetUnderTest(
WidgetTester tester,
Widget child, {
ThemeMode themeMode = ThemeMode.light,
double? textScale,
Size size = const Size(400, 800),
bool settle = true,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
themeMode: themeMode,
textScale: textScale,
settle: settle,
home: Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: child,
),
),
);
}
const themeModes = [ThemeMode.light, ThemeMode.dark];

View file

@ -0,0 +1,266 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:shuffle/features/auth/data/auth_failure_mapper.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
AuthApiException _api(String code, {String status = '400'}) =>
AuthApiException('msg', statusCode: status, code: code);
const _triggerBody = '{"code":"P0001","message":"display_name_invalid"}';
void main() {
group('Spec #1 § 4.4 error mapping', () {
test('KTO-01 Kriterium 7: invalid_credentials', () {
expect(
mapAuthError(_api('invalid_credentials'), AuthOperation.signIn),
const AuthFailure.invalidCredentials(),
);
});
test('KTO-01 Kriterium 6: email_not_confirmed', () {
expect(
mapAuthError(_api('email_not_confirmed'), AuthOperation.signIn),
const AuthFailure.emailNotConfirmed(),
);
});
test('KTO-01 Kriterium 4: over_email_send_rate_limit -> emailSendLimited '
'for signUp, reset and resend', () {
for (final op in [
AuthOperation.signUp,
AuthOperation.requestPasswordReset,
AuthOperation.resend,
]) {
expect(
mapAuthError(_api('over_email_send_rate_limit', status: '429'), op),
const AuthFailure.emailSendLimited(),
reason: op.name,
);
}
});
test('over_email_send_rate_limit elsewhere is rateLimited', () {
expect(
mapAuthError(
_api('over_email_send_rate_limit', status: '429'),
AuthOperation.updatePassword,
),
const AuthFailure.rateLimited(),
);
});
test('KTO-01 Kriterium 7: over_request_rate_limit -> rateLimited', () {
expect(
mapAuthError(
_api('over_request_rate_limit', status: '429'),
AuthOperation.signIn,
),
const AuthFailure.rateLimited(),
);
});
test('any other HTTP 429 (also non-JSON body) -> rateLimited', () {
expect(
mapAuthError(
const AuthApiException('slow down', statusCode: '429'),
AuthOperation.signIn,
),
const AuthFailure.rateLimited(),
);
});
test('KTO-01 Kriterium 3: AuthWeakPasswordException with reasons', () {
expect(
mapAuthError(
AuthWeakPasswordException(
message: 'weak',
statusCode: '422',
reasons: const ['length', 'characters', 'pwned', 'other'],
),
AuthOperation.signUp,
),
const AuthFailure.weakPassword([
WeakPasswordReason.length,
WeakPasswordReason.characters,
WeakPasswordReason.pwned,
WeakPasswordReason.unknown,
]),
);
});
test('weak_password code on a plain API exception', () {
expect(
mapAuthError(
_api('weak_password', status: '422'),
AuthOperation.updatePassword,
),
const AuthFailure.weakPassword([]),
);
});
test('same_password', () {
expect(
mapAuthError(
_api('same_password', status: '422'),
AuthOperation.updatePassword,
),
const AuthFailure.samePassword(),
);
});
test('KTO-01 Kriterium 8: otp_expired -> linkInvalidOrExpired', () {
expect(
mapAuthError(
_api('otp_expired', status: '403'),
AuthOperation.verifyLink,
),
const AuthFailure.linkInvalidOrExpired(),
);
});
test('KTO-01 Kriterium 8: 403 without known code on verifyOTP', () {
expect(
mapAuthError(
const AuthApiException('invalid', statusCode: '403'),
AuthOperation.verifyLink,
),
const AuthFailure.linkInvalidOrExpired(),
);
expect(
mapAuthError(
const AuthApiException('forbidden', statusCode: '403'),
AuthOperation.signIn,
),
const AuthFailure.unexpected(),
);
});
test('email_address_invalid and validation_failed -> invalidEmail', () {
expect(
mapAuthError(_api('email_address_invalid'), AuthOperation.signUp),
const AuthFailure.invalidEmail(),
);
expect(
mapAuthError(
_api('validation_failed', status: '422'),
AuthOperation.requestPasswordReset,
),
const AuthFailure.invalidEmail(),
);
});
test('KTO-01 Kriterium 2, 10: HTTP 500 on signUp (trigger error) -> '
'registrationRejected, decided by status only', () {
final error = AuthRetryableFetchException(
message: _triggerBody,
statusCode: '500',
);
expect(error.code, isNull);
expect(
mapAuthError(error, AuthOperation.signUp),
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
);
});
test('HTTP 500 on signUp with unknown body keeps registrationRejected', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'oops', statusCode: '500'),
AuthOperation.signUp,
),
const AuthFailure.registrationRejected(),
);
});
test('trigger messages map to reasons', () {
expect(
registrationRejectionReasonFrom('age_confirmation_required'),
RegistrationRejectionReason.ageConfirmation,
);
expect(
registrationRejectionReasonFrom('terms_acceptance_required'),
RegistrationRejectionReason.termsAcceptance,
);
expect(
registrationRejectionReasonFrom('terms_version_required'),
RegistrationRejectionReason.termsVersion,
);
});
test('HTTP 500 outside signUp is a network failure', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: _triggerBody, statusCode: '500'),
AuthOperation.signIn,
),
const AuthFailure.network(),
);
});
test('other 5xx on signUp (gateway) -> network', () {
expect(
mapAuthError(
AuthRetryableFetchException(
message: 'bad gateway',
statusCode: '502',
),
AuthOperation.signUp,
),
const AuthFailure.network(),
);
});
test('Zustände Krit. 2: AuthRetryableFetchException without status', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'Failed host lookup'),
AuthOperation.signIn,
),
const AuthFailure.network(),
);
});
test('TimeoutException and raw transport errors -> network', () {
expect(
mapAuthError(TimeoutException('t'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(const SocketException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(http.ClientException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(const HandshakeException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
});
test('everything else -> unexpected', () {
expect(
mapAuthError(StateError('x'), AuthOperation.signIn),
const AuthFailure.unexpected(),
);
expect(
mapAuthError(_api('something_new'), AuthOperation.signIn),
const AuthFailure.unexpected(),
);
expect(
mapAuthError(
AuthSessionMissingException(),
AuthOperation.updatePassword,
),
const AuthFailure.unexpected(),
);
});
});
}

View file

@ -0,0 +1,105 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/data/auth_status_tracker.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
import 'auth_test_support.dart';
void main() {
group('KTO-01 Kriterium 5: AuthStatusTracker event mapping', () {
test('no session -> signedOut', () {
final tracker = AuthStatusTracker();
expect(tracker.statusFor(null), const AuthStatus.signedOut());
expect(
tracker.handle(AuthChangeEvent.initialSession, null),
const AuthStatus.signedOut(),
);
});
test(
'signedIn, tokenRefreshed, userUpdated, initialSession -> signedIn',
() {
final tracker = AuthStatusTracker();
const user = AuthUser(
id: 'user-1',
email: 'anna@example.com',
emailConfirmed: true,
);
for (final event in [
AuthChangeEvent.initialSession,
AuthChangeEvent.signedIn,
AuthChangeEvent.tokenRefreshed,
AuthChangeEvent.userUpdated,
]) {
expect(
tracker.handle(event, testSession()),
const AuthStatus.signedIn(user),
reason: event.name,
);
}
},
);
test('unconfirmed email is reflected in the user', () {
final status = AuthStatusTracker().handle(
AuthChangeEvent.signedIn,
testSession(user: testUser(confirmed: false)),
);
expect((status as SignedIn).user.emailConfirmed, isFalse);
});
test('signedOut -> signedOut', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.signedIn, testSession());
expect(
tracker.handle(AuthChangeEvent.signedOut, null),
const AuthStatus.signedOut(),
);
});
});
group('KTO-01 Kriterium 8: recovery guard (process only)', () {
test('passwordRecovery -> passwordRecovery', () {
final tracker = AuthStatusTracker();
expect(
tracker.handle(AuthChangeEvent.passwordRecovery, testSession()),
isA<PasswordRecovery>(),
);
});
test('guard survives tokenRefreshed and signedIn during recovery', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
expect(
tracker.handle(AuthChangeEvent.tokenRefreshed, testSession()),
isA<PasswordRecovery>(),
);
expect(
tracker.handle(AuthChangeEvent.signedIn, testSession()),
isA<PasswordRecovery>(),
);
expect(tracker.statusFor(testSession()), isA<PasswordRecovery>());
});
test('userUpdated (password changed) ends the recovery', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
expect(
tracker.handle(AuthChangeEvent.userUpdated, testSession()),
isA<SignedIn>(),
);
});
test('signedOut (abort) ends the recovery', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
tracker.handle(AuthChangeEvent.signedOut, null);
expect(tracker.statusFor(testSession()), isA<SignedIn>());
});
test('a fresh tracker never derives recovery from a session (reload)', () {
expect(AuthStatusTracker().statusFor(testSession()), isA<SignedIn>());
});
});
}

View file

@ -0,0 +1,24 @@
import 'package:supabase_flutter/supabase_flutter.dart';
User testUser({
String id = 'user-1',
String? email = 'anna@example.com',
bool confirmed = true,
List<UserIdentity>? identities,
}) => User(
id: id,
appMetadata: const {},
userMetadata: const {},
aud: 'authenticated',
createdAt: '2026-11-02T10:00:00Z',
email: email,
emailConfirmedAt: confirmed ? '2026-11-02T10:05:00Z' : null,
identities: identities,
);
Session testSession({User? user}) => Session(
accessToken: 'access',
tokenType: 'bearer',
refreshToken: 'refresh',
user: user ?? testUser(),
);

View file

@ -0,0 +1,80 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/data/asset_common_password_list.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('KTO-01 Kriterium 3: common password asset', () {
late List<String> lines;
setUpAll(() {
lines = File(AssetCommonPasswordList.assetPath)
.readAsStringSync()
.split('\n')
.where((l) => l.isNotEmpty)
.toList();
});
test('has a bounded size', () {
expect(lines.length, inInclusiveRange(5000, 15000));
expect(
File(AssetCommonPasswordList.assetPath).lengthSync(),
lessThan(200 * 1024),
);
});
test('contains only normalized entries of at least 10 code points', () {
for (final line in lines) {
expect(line.runes.length, greaterThanOrEqualTo(10), reason: line);
expect(line, line.trim(), reason: line);
expect(line, line.toLowerCase(), reason: line);
expect(line.startsWith('#'), isFalse, reason: line);
}
});
test('has no duplicates', () {
expect(lines.toSet().length, lines.length);
});
test('is declared as a bundle asset and loads lazily into a set', () async {
final list = await const AssetCommonPasswordList().load();
expect(list.contains('passwort1234'), isTrue);
expect(list.contains('Hallo12345'), isTrue);
expect(list.contains(' PASSWORT1234 '), isTrue);
expect(list.contains('korrekt-pferd-batterie-heftklammer'), isFalse);
});
test('fails loudly if the asset is missing', () async {
await expectLater(
AssetCommonPasswordList(bundle: _EmptyBundle()).load(),
throwsA(isA<FlutterError>()),
);
});
});
group('parseCommonPasswords', () {
test('normalizes lines and skips blanks and comments', () {
final list = parseCommonPasswords(
'# c\n\n ABCDEFGHIJ \r\nklmnopqrstu\n',
);
expect(list.contains('abcdefghij'), isTrue);
expect(list.contains('klmnopqrstu'), isTrue);
expect(list.contains('# c'), isFalse);
expect(list.contains(''), isFalse);
});
});
}
class _EmptyBundle extends CachingAssetBundle {
_EmptyBundle();
@override
Future<ByteData> load(String key) =>
Future.error(FlutterError('missing asset $key'));
}

View file

@ -0,0 +1,359 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/features/auth/data/supabase_auth_repository.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
import '../../../helpers/fakes.dart';
import 'auth_test_support.dart';
const _input = RegistrationInput(
email: ' anna@example.com ',
password: ' geheimes-passwort ',
displayName: ' Anna ',
ageConfirmed: true,
termsAccepted: true,
termsVersion: 'test-1',
locale: 'de',
);
void main() {
late MockGoTrueClient auth;
late StreamController<AuthState> events;
late Session? currentSession;
late SupabaseAuthRepository repository;
setUpAll(() {
registerFallbackValue(OtpType.signup);
registerFallbackValue(UserAttributes());
});
setUp(() {
auth = MockGoTrueClient();
events = StreamController<AuthState>.broadcast(sync: true);
currentSession = null;
when(() => auth.currentSession).thenAnswer((_) => currentSession);
when(() => auth.onAuthStateChange).thenAnswer((_) => events.stream);
repository = SupabaseAuthRepository(auth);
});
tearDown(() async {
await repository.dispose();
await events.close();
});
group('KTO-01 Kriterium 1: signUp', () {
test('sends trimmed values and JSON-boolean metadata', () async {
Map<Symbol, dynamic>? args;
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenAnswer((invocation) async {
args = invocation.namedArguments;
return AuthResponse();
});
final result = await repository.signUp(_input);
expect(result.isSuccess, isTrue);
expect(args![#email], 'anna@example.com');
expect(args![#password], ' geheimes-passwort ', reason: 'untouched');
final captured = [args![#email], args![#password], args![#data]];
final data = captured[2] as Map<String, dynamic>;
expect(data, {
'display_name': 'Anna',
'age_confirmed': true,
'terms_accepted': true,
'terms_version': 'test-1',
'locale': 'de',
});
expect(data['age_confirmed'], isA<bool>());
expect(data['terms_accepted'], isA<bool>());
});
test(
'KTO-01 Kriterium 4: 200 with empty identities is plain success',
() async {
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenAnswer(
(_) async => AuthResponse(user: testUser(identities: const [])),
);
final result = await repository.signUp(_input);
expect(result.isSuccess, isTrue);
expect(result.errorOrNull, isNull);
},
);
test(
'KTO-01 Kriterium 10: trigger error (HTTP 500) is registrationRejected',
() async {
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenThrow(
AuthRetryableFetchException(
message: '{"code":"P0001","message":"display_name_invalid"}',
statusCode: '500',
),
);
final result = await repository.signUp(_input);
expect(
result.errorOrNull,
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
);
},
);
test(
'KTO-01 Kriterium 4: over_email_send_rate_limit -> emailSendLimited',
() async {
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenThrow(
const AuthApiException(
'email rate limit exceeded',
statusCode: '429',
code: 'over_email_send_rate_limit',
),
);
expect(
(await repository.signUp(_input)).errorOrNull,
const AuthFailure.emailSendLimited(),
);
},
);
});
group('other calls', () {
test(
'KTO-01 Kriterium 6: resend uses OtpType.signup and a trimmed email',
() async {
when(
() => auth.resend(
type: any(named: 'type'),
email: any(named: 'email'),
),
).thenAnswer((_) async => ResendResponse());
final result = await repository.resendConfirmation(
' anna@example.com ',
);
expect(result.isSuccess, isTrue);
verify(
() => auth.resend(type: OtpType.signup, email: 'anna@example.com'),
).called(1);
},
);
test('KTO-01 Kriterium 7: signIn maps invalid_credentials', () async {
when(
() => auth.signInWithPassword(
email: any(named: 'email'),
password: any(named: 'password'),
),
).thenThrow(
const AuthApiException(
'Invalid login credentials',
statusCode: '400',
code: 'invalid_credentials',
),
);
final result = await repository.signIn('anna@example.com', 'x');
expect(result.errorOrNull, const AuthFailure.invalidCredentials());
});
test('KTO-01 Kriterium 8: requestPasswordReset', () async {
when(() => auth.resetPasswordForEmail(any())).thenAnswer((_) async {});
final result = await repository.requestPasswordReset(' anna@example.com');
expect(result.isSuccess, isTrue);
verify(() => auth.resetPasswordForEmail('anna@example.com')).called(1);
});
test(
'KTO-01 Kriterium 8: recovery link sends only token hash and type',
() async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenAnswer((_) async => AuthResponse(session: testSession()));
final result = await repository.verifyEmailLink(
'hash',
EmailLinkType.recovery,
);
expect(result.isSuccess, isTrue);
verify(() => auth.verifyOTP(type: OtpType.recovery, tokenHash: 'hash'))
.called(1);
},
);
test('KTO-01 Kriterium 1: confirmation link uses OtpType.email', () async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenAnswer((_) async => AuthResponse(session: testSession()));
await repository.verifyEmailLink('hash', EmailLinkType.confirmation);
verify(() => auth.verifyOTP(type: OtpType.email, tokenHash: 'hash'))
.called(1);
});
test('KTO-01 Kriterium 8: used link -> linkInvalidOrExpired', () async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenThrow(
const AuthApiException(
'Email link is invalid or has expired',
statusCode: '403',
code: 'otp_expired',
),
);
final result = await repository.verifyEmailLink(
'hash',
EmailLinkType.recovery,
);
expect(result.errorOrNull, const AuthFailure.linkInvalidOrExpired());
});
test('verify without a session is unexpected', () async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenAnswer((_) async => AuthResponse());
final result = await repository.verifyEmailLink(
'hash',
EmailLinkType.confirmation,
);
expect(result.errorOrNull, const AuthFailure.unexpected());
});
test('KTO-01 Kriterium 8: setNewPassword maps same_password', () async {
when(() => auth.updateUser(any())).thenThrow(
const AuthApiException(
'same',
statusCode: '422',
code: 'same_password',
),
);
final result = await repository.setNewPassword('neues-passwort');
expect(result.errorOrNull, const AuthFailure.samePassword());
});
test('setNewPassword sends the password attribute', () async {
when(() => auth.updateUser(any()))
.thenAnswer((_) async => UserResponse.fromJson(testUser().toJson()));
final result = await repository.setNewPassword('neues-passwort');
expect(result.isSuccess, isTrue);
final attrs =
verify(() => auth.updateUser(captureAny())).captured.single
as UserAttributes;
expect(attrs.password, 'neues-passwort');
});
test('signOut swallows errors', () async {
when(() => auth.signOut()).thenThrow(StateError('boom'));
await expectLater(repository.signOut(), completes);
});
});
group('KTO-01 Kriterium 5, 8: status', () {
test('currentStatus reflects the stored session at construction', () async {
currentSession = testSession();
final restored = SupabaseAuthRepository(auth);
addTearDown(restored.dispose);
expect(restored.currentStatus, isA<SignedIn>());
});
test('watchStatus emits the current value, then changes', () async {
final emitted = <AuthStatus>[];
final sub = repository.watchStatus().listen(emitted.add);
addTearDown(sub.cancel);
await Future<void>.delayed(Duration.zero);
events.add(AuthState(AuthChangeEvent.signedIn, testSession()));
events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession()));
events.add(AuthState(AuthChangeEvent.userUpdated, testSession()));
events.add(const AuthState(AuthChangeEvent.signedOut, null));
await Future<void>.delayed(Duration.zero);
expect(emitted.map((s) => s.runtimeType.toString()), [
'SignedOut',
'SignedIn',
'PasswordRecovery',
'SignedIn',
'SignedOut',
]);
});
test('identical statuses (token refresh) are not re-emitted', () async {
final emitted = <AuthStatus>[];
final sub = repository.watchStatus().listen(emitted.add);
addTearDown(sub.cancel);
events.add(AuthState(AuthChangeEvent.signedIn, testSession()));
events.add(AuthState(AuthChangeEvent.tokenRefreshed, testSession()));
await Future<void>.delayed(Duration.zero);
expect(emitted, hasLength(2));
});
test('recovery from a verified link is visible synchronously', () async {
events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession()));
expect(repository.currentStatus, isA<PasswordRecovery>());
});
});
}

View file

@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/resend_origin.dart';
void main() {
group('KTO-01 Kriterium 6: resendFailureFor (F12)', () {
const limited = AuthFailure.emailSendLimited();
test('check-email screen treats emailSendLimited as success', () {
expect(resendFailureFor(limited, ResendOrigin.checkEmail), isNull);
});
test('login screen shows the throttling hint', () {
expect(
resendFailureFor(limited, ResendOrigin.login),
const AuthFailure.rateLimited(),
);
});
test('other failures pass through unchanged', () {
for (final origin in ResendOrigin.values) {
expect(
resendFailureFor(const AuthFailure.rateLimited(), origin),
const AuthFailure.rateLimited(),
);
expect(
resendFailureFor(const AuthFailure.network(), origin),
const AuthFailure.network(),
);
}
});
});
group('KTO-01 Kriterium 4: openEmailInputFailureFor (F12)', () {
test('emailSendLimited counts as success', () {
expect(
openEmailInputFailureFor(const AuthFailure.emailSendLimited()),
isNull,
);
});
test('rateLimited stays visible', () {
expect(
openEmailInputFailureFor(const AuthFailure.rateLimited()),
const AuthFailure.rateLimited(),
);
});
});
group('KTO-01 Kriterium 8: EmailLinkType.tryParse', () {
test('maps email and signup to confirmation, recovery to recovery', () {
expect(EmailLinkType.tryParse('email'), EmailLinkType.confirmation);
expect(EmailLinkType.tryParse('signup'), EmailLinkType.confirmation);
expect(EmailLinkType.tryParse('recovery'), EmailLinkType.recovery);
});
test('rejects missing and unknown types', () {
expect(EmailLinkType.tryParse(null), isNull);
expect(EmailLinkType.tryParse(''), isNull);
expect(EmailLinkType.tryParse('magiclink'), isNull);
expect(EmailLinkType.tryParse('invite'), isNull);
});
});
}

View file

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
void main() {
group('KTO-01 Kriterium 10: DisplayNameRule', () {
test('accepts names of 1 to 30 code points', () {
expect(DisplayNameRule.validate('A'), isNull);
expect(DisplayNameRule.validate('a' * 30), isNull);
});
test('rejects empty and whitespace-only names', () {
expect(DisplayNameRule.validate(''), DisplayNameError.empty);
expect(DisplayNameRule.validate(' '), DisplayNameError.empty);
expect(DisplayNameRule.validate('\t \n'), DisplayNameError.empty);
});
test('rejects 31 code points', () {
expect(DisplayNameRule.validate('a' * 31), DisplayNameError.tooLong);
});
test('counts code points, not UTF-16 units (emoji)', () {
expect(DisplayNameRule.validate('😀' * 30), isNull);
expect(DisplayNameRule.validate('😀' * 31), DisplayNameError.tooLong);
expect(DisplayNameRule.length('😀' * 30), 30);
expect('😀' * 30, hasLength(60));
});
test('counts every code point of a ZWJ emoji sequence', () {
// Family emoji: 3 emoji + 2 ZWJ = 5 code points.
expect(DisplayNameRule.length('👨‍👩‍👧'), 5);
expect(DisplayNameRule.validate('👨‍👩‍👧' * 6), isNull);
expect(
DisplayNameRule.validate('👨‍👩‍👧' * 6 + 'x'),
DisplayNameError.tooLong,
);
});
test('trims NBSP, tab, BOM and ideographic space like the server', () {
expect(DisplayNameRule.normalize(' \tAnna '), 'Anna');
expect(DisplayNameRule.validate('  '), DisplayNameError.empty);
expect(DisplayNameRule.validate(' ${'a' * 30}\t'), isNull);
});
test('counts only the trimmed name', () {
expect(DisplayNameRule.validate(' ${'a' * 30} '), isNull);
expect(DisplayNameRule.length(' ab '), 2);
});
test('rejects U+200B anywhere, even alone or at the edges', () {
const invalid = DisplayNameError.invalidCharacter;
expect(DisplayNameRule.validate('​'), invalid);
expect(DisplayNameRule.validate('Anna​'), invalid);
expect(DisplayNameRule.validate('​Anna'), invalid);
expect(DisplayNameRule.validate('An​na'), invalid);
expect(DisplayNameRule.validate(' ​ '), invalid);
});
test('does not treat ZWJ/ZWNJ as forbidden', () {
expect(DisplayNameRule.validate('a‍b‌'), isNull);
});
});
}

View file

@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
void main() {
group('KTO-01 Kriterium 1: EmailFormat', () {
test('accepts common addresses (trimmed)', () {
expect(EmailFormat.validate('anna@example.com'), isNull);
expect(EmailFormat.validate(' anna.b+x@mail.example.de '), isNull);
});
test('rejects empty input', () {
expect(EmailFormat.validate(''), EmailError.empty);
expect(EmailFormat.validate(' '), EmailError.empty);
});
test('rejects malformed addresses', () {
for (final value in [
'anna',
'anna@',
'@example.com',
'anna@example',
'anna@@example.com',
'an na@example.com',
'anna@example..com',
'anna@.com',
]) {
expect(EmailFormat.validate(value), EmailError.invalid, reason: value);
}
});
test('rejects addresses longer than 254 characters', () {
expect(
EmailFormat.validate('${'a' * 250}@example.com'),
EmailError.invalid,
);
});
});
}

View file

@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
void main() {
const common = SetCommonPasswordList({'passwort1234', 'hallo12345'});
group('KTO-01 Kriterium 3: PasswordPolicy', () {
test('rejects 9 characters and reports how many are missing', () {
expect(
PasswordPolicy.validate('123456789'),
const PasswordError.tooShort(1),
);
expect(PasswordPolicy.validate(''), const PasswordError.tooShort(10));
});
test('accepts exactly 10 characters', () {
expect(PasswordPolicy.validate('abcdefghij'), isNull);
});
test('counts code points, not UTF-16 units', () {
expect(
PasswordPolicy.validate('😀' * 9),
const PasswordError.tooShort(1),
);
expect(PasswordPolicy.validate('😀' * 10), isNull);
});
test('does not trim for the length check', () {
expect(PasswordPolicy.validate(' a'), isNull);
});
test('rejects common passwords ignoring case', () {
expect(
PasswordPolicy.validate('Passwort1234', common: common),
const PasswordError.common(),
);
expect(
PasswordPolicy.validate('HALLO12345', common: common),
const PasswordError.common(),
);
});
test('compares common passwords after trim', () {
expect(
PasswordPolicy.validate(' hallo12345 ', common: common),
const PasswordError.common(),
);
});
test('too short wins over common', () {
expect(
PasswordPolicy.validate(
'hallo',
common: const SetCommonPasswordList({'hallo'}),
),
const PasswordError.tooShort(5),
);
});
test('checks only the length while the list is not loaded', () {
expect(PasswordPolicy.validate('passwort1234'), isNull);
});
test('accepts an uncommon long password', () {
expect(
PasswordPolicy.validate('korrekt-pferd-batterie', common: common),
isNull,
);
});
});
}

View file

@ -0,0 +1,154 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
const _user = AuthUser(
id: 'u1',
email: 'anna@example.com',
emailConfirmed: true,
);
const _signedOut = AuthStatus.signedOut();
const _signedIn = AuthStatus.signedIn(_user);
const _recovery = AuthStatus.passwordRecovery(_user);
String? _redirect(
AuthStatus status,
String location, {
List<String> privatePaths = const [],
}) => authRedirectFor(status, Uri.parse(location), privatePaths: privatePaths);
void main() {
group('KTO-01 Kriterium 8: passwordRecovery', () {
test('every route except /reset-password leads to /reset-password', () {
for (final path in [
'/',
'/login',
'/register',
'/forgot-password',
'/auth/confirm?token_hash=x&type=recovery',
'/anything',
]) {
expect(
_redirect(_recovery, path),
AppRoutes.resetPassword,
reason: path,
);
}
});
test('/reset-password stays', () {
expect(_redirect(_recovery, '/reset-password'), isNull);
});
});
group('KTO-01 Kriterium 5: signedIn', () {
test('guest-only pages lead to the start page', () {
for (final path in ['/login', '/register', '/forgot-password']) {
expect(_redirect(_signedIn, path), AppRoutes.home, reason: path);
}
});
test('login honours a safe from parameter', () {
expect(
_redirect(_signedIn, '/login?from=%2Fprivate%3Fa%3D1'),
'/private?a=1',
);
});
test('unsafe from values fall back to the start page', () {
for (final from in [
'https://evil.example',
'//evil.example',
r'/\evil.example',
'private',
'/login',
'/reset-password',
'/auth/confirm',
]) {
expect(
_redirect(_signedIn, '/login?from=${Uri.encodeComponent(from)}'),
AppRoutes.home,
reason: from,
);
}
});
test('public pages, check-email and the link page stay', () {
for (final path in ['/', '/register/check-email', '/auth/confirm?x=1']) {
expect(_redirect(_signedIn, path), isNull, reason: path);
}
});
test('/reset-password is only for passwordRecovery', () {
expect(_redirect(_signedIn, '/reset-password'), AppRoutes.home);
});
});
group('Sicherheit Kriterium 2: signedOut', () {
test('public pages stay reachable', () {
for (final path in [
'/',
'/login',
'/register',
'/register/check-email',
'/forgot-password',
'/auth/confirm?token_hash=x&type=email',
'/does-not-exist',
]) {
expect(_redirect(_signedOut, path), isNull, reason: path);
}
});
test('/reset-password is only for passwordRecovery', () {
expect(_redirect(_signedOut, '/reset-password'), AppRoutes.login);
});
test('private routes lead to /login?from=<path>', () {
final target = _redirect(
_signedOut,
'/collection/42?tab=games',
privatePaths: ['/collection'],
);
final uri = Uri.parse(target!);
expect(uri.path, AppRoutes.login);
expect(uri.queryParameters['from'], '/collection/42?tab=games');
});
test('private prefix matches whole segments only', () {
expect(
_redirect(_signedOut, '/collections', privatePaths: ['/collection']),
isNull,
);
expect(
_redirect(_signedOut, '/collection', privatePaths: ['/collection']),
isNotNull,
);
});
test('without private paths nothing is private (404 stays a 404)', () {
expect(_redirect(_signedOut, '/collection'), isNull);
});
});
group('safeFromPath', () {
test('rejects auth pages with a trailing slash', () {
expect(safeFromPath('/login/'), isNull);
expect(safeFromPath('/login//'), isNull);
expect(safeFromPath('/login/?x=1'), isNull);
expect(safeFromPath('/reset-password/'), isNull);
});
test('accepts internal paths with query', () {
expect(safeFromPath('/games/1?x=2'), '/games/1?x=2');
});
test('rejects null, empty and absolute URLs', () {
expect(safeFromPath(null), isNull);
expect(safeFromPath(''), isNull);
expect(safeFromPath('javascript:alert(1)'), isNull);
});
});
}

View file

@ -0,0 +1,247 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/routing/app_router.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
import '../../../helpers/fakes.dart';
import '../../../helpers/pump_app.dart';
import '../../../helpers/router_helpers.dart';
import '../data/auth_test_support.dart';
void main() {
late SupabaseMocks mocks;
late StreamController<AuthState> events;
late Session? session;
late int signedOutCalls;
setUp(() {
mocks = SupabaseMocks();
events = StreamController<AuthState>.broadcast(sync: true);
session = null;
signedOutCalls = 0;
when(() => mocks.auth.currentSession).thenAnswer((_) => session);
when(() => mocks.auth.onAuthStateChange).thenAnswer((_) => events.stream);
});
tearDown(() => events.close());
void emit(AuthChangeEvent event, {Session? withSession}) {
session = withSession;
events.add(AuthState(event, withSession));
}
Future<ProviderContainerAndRouter> start(WidgetTester tester) async {
final container = await pumpShuffleApp(
tester,
overrides: testOverrides(
supabase: mocks.client,
overrides: [
...authOverrides(),
privateRoutePathsProvider.overrideWithValue(const ['/private']),
signedOutHandlersProvider.overrideWithValue([
(ref) => signedOutCalls++,
]),
],
),
);
return (container: container, router: container.read(appRouterProvider));
}
group('KTO-01 Kriterium 5: authStatusProvider', () {
testWidgets('maps events and starts with the stored session', (
tester,
) async {
session = testSession();
final (:container, router: _) = await start(tester);
expect(container.read(authStatusProvider).value, isA<SignedIn>());
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(container.read(authStatusProvider).value, isA<SignedOut>());
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(container.read(authStatusProvider).value, isA<SignedIn>());
});
});
group('Sicherheit Kriterium 2: router redirects', () {
testWidgets('private route while signed out -> /login?from=', (
tester,
) async {
final (container: _, :router) = await start(tester);
router.go('/private?tab=1');
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
expect(currentUri(router).queryParameters['from'], '/private?tab=1');
expect(find.byKey(const Key('auth_placeholder_login')), findsOneWidget);
});
testWidgets('signing in on /login returns to from', (tester) async {
final (container: _, :router) = await start(tester);
router.go('/private');
await tester.pumpAndSettle();
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/private');
});
testWidgets('signed in: login and register lead to the start page', (
tester,
) async {
session = testSession();
final (container: _, :router) = await start(tester);
router.go('/login');
await tester.pumpAndSettle();
expect(currentPath(router), '/');
router.go('/register');
await tester.pumpAndSettle();
expect(currentPath(router), '/');
});
testWidgets(
'routes of spec 5.3 exist (01a: real screens, rest placeholders)',
(tester) async {
final (container: _, :router) = await start(tester);
for (final (path, key) in [
('/login', 'auth_placeholder_login'),
('/register', 'register_screen'),
('/register/check-email', 'check_email_screen'),
('/forgot-password', 'auth_placeholder_forgot_password'),
('/auth/confirm?token_hash=x&type=email', 'email_link_screen'),
]) {
router.go(path);
await tester.pumpAndSettle();
expect(find.byKey(Key(key)), findsOneWidget, reason: path);
}
},
);
});
group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () {
testWidgets('recovery forces /reset-password until the password changed', (
tester,
) async {
final (container: _, :router) = await start(tester);
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
expect(
find.byKey(const Key('auth_placeholder_reset_password')),
findsOneWidget,
);
router.go('/');
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
emit(AuthChangeEvent.tokenRefreshed, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
emit(AuthChangeEvent.userUpdated, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/');
});
testWidgets('aborting the recovery (signOut) leaves /reset-password', (
tester,
) async {
final (container: _, :router) = await start(tester);
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
await tester.pumpAndSettle();
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
});
testWidgets('onSignedOut runs on signed-in -> signed-out only', (
tester,
) async {
await start(tester);
expect(signedOutCalls, 0, reason: 'initial signed-out state');
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(signedOutCalls, 0);
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(signedOutCalls, 1);
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(signedOutCalls, 1, reason: 'no transition');
});
testWidgets('a failed refresh on a private route ends on /login', (
tester,
) async {
session = testSession();
final (container: _, :router) = await start(tester);
router.go('/private');
await tester.pumpAndSettle();
expect(currentPath(router), '/private');
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
expect(signedOutCalls, 1);
});
});
group('KTO-01 Kriterium 1: registrationInputFactory', () {
testWidgets('fills terms_version from AppConfig and the app language', (
tester,
) async {
final (:container, router: _) = await start(tester);
final input = container.read(registrationInputFactoryProvider)(
email: 'a@b.de',
password: 'pw',
displayName: 'Anna',
ageConfirmed: true,
termsAccepted: true,
);
expect(
input,
const RegistrationInput(
email: 'a@b.de',
password: 'pw',
displayName: 'Anna',
ageConfirmed: true,
termsAccepted: true,
termsVersion: 'test-1',
locale: 'en',
),
);
});
});
}
typedef ProviderContainerAndRouter = ({
ProviderContainer container,
GoRouter router,
});

View file

@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/l10n/app_locale.dart';
import 'package:shuffle/core/platform/platform_info.dart';
import 'package:shuffle/core/platform/url_opener.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_repository.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
import 'package:shuffle/features/auth/presentation/auth_routes.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import '../../../helpers/fakes.dart';
import '../../../helpers/pump_app.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
class FakeRegistrationInput extends Fake implements RegistrationInput {}
/// [AppLocale] with a fixed language (tests do not depend on the device).
class FixedAppLocale extends AppLocale {
FixedAppLocale(this.locale);
final Locale locale;
@override
Locale build() => locale;
}
const commonTestPasswords = SetCommonPasswordList({'passwort1234'});
void registerAuthFallbacks() {
registerFallbackValue(FakeRegistrationInput());
registerFallbackValue(EmailLinkType.confirmation);
}
Result<void, AuthFailure> ok() => const Result.success(null);
Result<void, AuthFailure> failed(AuthFailure value) => Result.failure(value);
/// Overrides for auth UI tests: repository mock, fixed language, common
/// password list, URL opener and platform.
List<Override> authUiOverrides(
MockAuthRepository repository, {
Locale locale = const Locale('de'),
UrlOpener? urlOpener,
bool mobileWeb = false,
Future<CommonPasswordList> Function()? commonPasswords,
List<Override> extra = const [],
}) => testOverrides(
overrides: [
authRepositoryProvider.overrideWithValue(repository),
appLocaleProvider.overrideWith(() => FixedAppLocale(locale)),
commonPasswordListProvider.overrideWith(
(ref) => commonPasswords?.call() ?? Future.value(commonTestPasswords),
),
urlOpenerProvider.overrideWithValue(urlOpener ?? (_) async => true),
isMobileWebProvider.overrideWithValue(mobileWeb),
...extra,
],
);
/// The text field inside an `AppTextField`/`AppPasswordField` with [key].
Finder fieldOf(String key) =>
find.descendant(of: find.byKey(Key(key)), matching: find.byType(TextField));
/// Keeps an auto-dispose provider alive during a controller test.
void keepAlive(ProviderContainer container, ProviderListenable<Object?> p) {
container.listen(p, (_, _) {});
}
typedef AuthUiHandle = ({ProviderContainer container, GoRouter router});
/// Pumps the real auth routes (register, check-email, confirm; the others are
/// placeholders) at [location] on a phone-sized screen.
Future<AuthUiHandle> pumpAuthRoute(
WidgetTester tester,
MockAuthRepository repository, {
String location = '/register',
Object? extra,
Locale locale = const Locale('de'),
ThemeMode themeMode = ThemeMode.light,
double? textScale,
Size size = const Size(400, 800),
UrlOpener? urlOpener,
bool mobileWeb = false,
bool settle = true,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final router = GoRouter(
initialLocation: location,
initialExtra: extra,
routes: authRoutes,
);
addTearDown(router.dispose);
final container = await pumpApp(
tester,
router: router,
locale: locale,
themeMode: themeMode,
textScale: textScale,
settle: settle,
overrides: authUiOverrides(
repository,
locale: locale,
urlOpener: urlOpener,
mobileWeb: mobileWeb,
),
);
return (container: container, router: router);
}
/// All texts currently visible (for comparing two screens exactly).
List<String> visibleTexts(WidgetTester tester) => [
for (final text in tester.widgetList<Text>(find.byType(Text)))
if (text.data != null) text.data!,
];

View file

@ -0,0 +1,223 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/l10n/app_localizations_de.dart';
import 'package:shuffle/l10n/app_localizations_en.dart';
import '../../../../helpers/router_helpers.dart';
import '../auth_ui_support.dart';
final _de = AppLocalizationsDe();
final _en = AppLocalizationsEn();
void main() {
late MockAuthRepository repository;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => ok());
});
Future<AuthUiHandle> open(
WidgetTester tester, {
Object? extra = 'anna@example.com',
Locale locale = const Locale('de'),
ThemeMode themeMode = ThemeMode.light,
double? textScale,
Size size = const Size(400, 800),
}) => pumpAuthRoute(
tester,
repository,
location: '/register/check-email',
extra: extra,
locale: locale,
themeMode: themeMode,
textScale: textScale,
size: size,
);
Future<void> tapResend(WidgetTester tester) async {
await tester.ensureVisible(find.byKey(const Key('check_email_resend')));
await tester.tap(find.byKey(const Key('check_email_resend')));
await tester.pumpAndSettle();
}
group('KTO-01 Kriterium 1, 4: neutral message', () {
testWidgets('shows body, validity and the delay hint always', (
tester,
) async {
await open(tester);
expect(find.text(_de.authCheckEmailTitle), findsOneWidget);
expect(find.text(_de.authCheckEmailBody), findsOneWidget);
expect(find.text(_de.authCheckEmailValidity('1 Stunde')), findsOneWidget);
expect(find.text(_de.authCheckEmailDelayHint), findsOneWidget);
expect(find.byKey(const Key('check_email_email')), findsNothing);
});
testWidgets('the text does not contain the address', (tester) async {
await open(tester);
expect(find.textContaining('anna@example.com'), findsNothing);
});
});
group('KTO-01 Kriterium 6: resend', () {
testWidgets('sends to the known address and confirms', (tester) async {
await open(tester);
await tapResend(tester);
verify(() => repository.resendConfirmation('anna@example.com')).called(1);
expect(find.text(_de.authCheckEmailResent), findsOneWidget);
});
testWidgets('F12: emailSendLimited gives exactly the same feedback', (
tester,
) async {
await open(tester);
await tapResend(tester);
final success = visibleTexts(tester);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
when(
() => repository.resendConfirmation(any()),
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
await open(tester);
await tapResend(tester);
final limited = visibleTexts(tester);
expect(limited, success);
expect(limited, isNot(contains(_de.authErrorRateLimited)));
expect(limited, contains(_de.authCheckEmailResent));
});
testWidgets('IP rate limit shows the rate-limit hint', (tester) async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.rateLimited()));
await open(tester);
await tapResend(tester);
expect(find.text(_de.authErrorRateLimited), findsOneWidget);
expect(find.text(_de.authCheckEmailResent), findsNothing);
});
testWidgets('offline shows the offline hint', (tester) async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.network()));
await open(tester);
await tapResend(tester);
expect(find.text(_de.authErrorNetwork), findsOneWidget);
});
testWidgets('the button is locked while sending (no double tap)', (
tester,
) async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) => completer.future);
await open(tester);
await tester.tap(find.byKey(const Key('check_email_resend')));
await tester.pump();
await tester.tap(find.byKey(const Key('check_email_resend')));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
verify(() => repository.resendConfirmation(any())).called(1);
completer.complete(ok());
await tester.pumpAndSettle();
});
});
group('KTO-01 Kriterium 6: address missing after a web reload', () {
testWidgets('asks for the address and validates it', (tester) async {
await open(tester, extra: null);
expect(find.byKey(const Key('check_email_email')), findsOneWidget);
await tapResend(tester);
expect(find.text(_de.authErrorEmailInvalid), findsOneWidget);
verifyNever(() => repository.resendConfirmation(any()));
await tester.enterText(
fieldOf('check_email_email'),
' berta@example.com ',
);
await tester.pump();
expect(find.text(_de.authErrorEmailInvalid), findsNothing);
await tapResend(tester);
verify(() => repository.resendConfirmation('berta@example.com'))
.called(1);
expect(find.text(_de.authCheckEmailResent), findsOneWidget);
});
testWidgets('an extra of the wrong type counts as missing', (tester) async {
await open(tester, extra: 42);
expect(find.byKey(const Key('check_email_email')), findsOneWidget);
});
});
testWidgets('back to sign in leads to /login', (tester) async {
final (:container, :router) = await open(tester);
await tester.ensureVisible(
find.byKey(const Key('check_email_back_to_login')),
);
await tester.tap(find.byKey(const Key('check_email_back_to_login')));
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
});
testWidgets('QUE-05 Kriterium 2: english texts', (tester) async {
await open(tester, locale: const Locale('en'));
expect(find.text(_en.authCheckEmailBody), findsOneWidget);
expect(find.text(_en.authCheckEmailValidity('1 hour')), findsOneWidget);
expect(find.text(_en.authCheckEmailDelayHint), findsOneWidget);
expect(find.text(_en.authCheckEmailResend), findsOneWidget);
});
group('QUE-06 Kriterien 1-4: accessibility', () {
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
for (final withEmail in [true, false]) {
testWidgets('meets the guidelines ($mode, address known: $withEmail)', (
tester,
) async {
final handle = tester.ensureSemantics();
await open(
tester,
themeMode: mode,
extra: withEmail ? 'anna@example.com' : null,
);
await tapResend(tester);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
}
testWidgets('text scale 2.0 on a small phone does not overflow', (
tester,
) async {
await open(tester, extra: null, textScale: 2, size: const Size(320, 640));
await tapResend(tester);
expect(tester.takeException(), isNull);
});
});
}

View file

@ -0,0 +1,122 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/resend_origin.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
import '../auth_ui_support.dart';
void main() {
late MockAuthRepository repository;
late ProviderContainer container;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
container = ProviderContainer.test(overrides: authUiOverrides(repository));
keepAlive(container, resendControllerProvider);
});
ResendController notifier() =>
container.read(resendControllerProvider.notifier);
ResendState state() => container.read(resendControllerProvider);
void stub(Result<void, AuthFailure> result) =>
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => result);
group('KTO-01 Kriterium 6: resend', () {
test('sends to the trimmed address and reports success', () async {
stub(ok());
await notifier().resend(' anna@example.com ', origin: ResendOrigin.login);
verify(() => repository.resendConfirmation('anna@example.com')).called(1);
expect(state().sent, isTrue);
expect(state().failure, isNull);
});
test('an invalid address is not sent', () async {
await notifier().resend('anna', origin: ResendOrigin.checkEmail);
verifyNever(() => repository.resendConfirmation(any()));
expect(state().emailError, EmailError.invalid);
notifier().clearEmailError();
expect(state().emailError, isNull);
});
test('the button is locked while a request runs', () async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) => completer.future);
final first = notifier().resend(
'anna@example.com',
origin: ResendOrigin.checkEmail,
);
await Future<void>.delayed(Duration.zero);
expect(state().isSending, isTrue);
await notifier().resend(
'anna@example.com',
origin: ResendOrigin.checkEmail,
);
completer.complete(ok());
await first;
verify(() => repository.resendConfirmation(any())).called(1);
expect(state().sent, isTrue);
});
});
group('KTO-01 Kriterium 4, 6 (F12): emailSendLimited', () {
test('from the check-email screen counts as sent', () async {
stub(failed(const AuthFailure.emailSendLimited()));
await notifier().resend(
'anna@example.com',
origin: ResendOrigin.checkEmail,
);
expect(state().sent, isTrue);
expect(state().failure, isNull);
});
test('from the login screen becomes the rate-limit failure', () async {
stub(failed(const AuthFailure.emailSendLimited()));
await notifier().resend('anna@example.com', origin: ResendOrigin.login);
expect(state().sent, isFalse);
expect(state().failure, const AuthFailure.rateLimited());
});
test('over_request_rate_limit is shown from both origins', () async {
stub(failed(const AuthFailure.rateLimited()));
await notifier().resend(
'anna@example.com',
origin: ResendOrigin.checkEmail,
);
expect(state().failure, const AuthFailure.rateLimited());
});
test('Zustände Kriterium 2: network failure is reported', () async {
stub(failed(const AuthFailure.network()));
await notifier().resend(
'anna@example.com',
origin: ResendOrigin.checkEmail,
);
expect(state().failure, const AuthFailure.network());
expect(state().isSending, isFalse);
});
});
}

View file

@ -0,0 +1,121 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/email_link_request.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/presentation/email_link/email_link_controller.dart';
import '../auth_ui_support.dart';
void main() {
late MockAuthRepository repository;
late ProviderContainer container;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
container = ProviderContainer.test(overrides: authUiOverrides(repository));
});
EmailLinkController start(String? token, String? type) {
final provider = emailLinkControllerProvider(token, type);
keepAlive(container, provider);
return container.read(provider.notifier);
}
EmailLinkState stateOf(String? token, String? type) =>
container.read(emailLinkControllerProvider(token, type));
group('KTO-01 Kriterium 8: parameters', () {
test('email and signup mean confirmation, recovery stays recovery', () {
expect(
EmailLinkRequest.tryParse('t', 'email')?.type,
EmailLinkType.confirmation,
);
expect(
EmailLinkRequest.tryParse('t', 'signup')?.type,
EmailLinkType.confirmation,
);
expect(
EmailLinkRequest.tryParse('t', 'recovery')?.type,
EmailLinkType.recovery,
);
});
test('missing token or unknown type give no request', () {
expect(EmailLinkRequest.tryParse(null, 'email'), isNull);
expect(EmailLinkRequest.tryParse(' ', 'email'), isNull);
expect(EmailLinkRequest.tryParse('t', null), isNull);
expect(EmailLinkRequest.tryParse('t', 'magiclink'), isNull);
});
test(
'invalid parameters start in the invalid state and never verify',
() async {
final controller = start(null, 'email');
expect(stateOf(null, 'email').phase, EmailLinkPhase.invalid);
await controller.confirm();
verifyNever(() => repository.verifyEmailLink(any(), any()));
},
);
});
group('KTO-01 Kriterium 1, 8: redeeming', () {
test('creating the controller does not redeem the link (E3)', () {
start('hash', 'email');
verifyNever(() => repository.verifyEmailLink(any(), any()));
expect(stateOf('hash', 'email').phase, EmailLinkPhase.ready);
});
test('confirm redeems once with token and type', () async {
when(() => repository.verifyEmailLink(any(), any()))
.thenAnswer((_) async => ok());
final controller = start('hash', 'signup');
await controller.confirm();
await controller.confirm();
verify(
() => repository.verifyEmailLink('hash', EmailLinkType.confirmation),
).called(1);
expect(stateOf('hash', 'signup').phase, EmailLinkPhase.success);
});
test('an expired or used link ends in the invalid state', () async {
when(() => repository.verifyEmailLink(any(), any())).thenAnswer(
(_) async => failed(const AuthFailure.linkInvalidOrExpired()),
);
final controller = start('hash', 'email');
await controller.confirm();
expect(stateOf('hash', 'email').phase, EmailLinkPhase.invalid);
});
test(
'Zustände Kriterium 2: a network failure allows another try',
() async {
when(() => repository.verifyEmailLink(any(), any()))
.thenAnswer((_) async => failed(const AuthFailure.network()));
final controller = start('hash', 'email');
await controller.confirm();
final state = stateOf('hash', 'email');
expect(state.phase, EmailLinkPhase.ready);
expect(state.failure, const AuthFailure.network());
when(() => repository.verifyEmailLink(any(), any()))
.thenAnswer((_) async => ok());
await controller.confirm();
expect(stateOf('hash', 'email').phase, EmailLinkPhase.success);
expect(stateOf('hash', 'email').failure, isNull);
},
);
});
}

View file

@ -0,0 +1,243 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/l10n/app_localizations_de.dart';
import 'package:shuffle/l10n/app_localizations_en.dart';
import '../../../../helpers/router_helpers.dart';
import '../auth_ui_support.dart';
final _de = AppLocalizationsDe();
final _en = AppLocalizationsEn();
void main() {
late MockAuthRepository repository;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
when(() => repository.verifyEmailLink(any(), any()))
.thenAnswer((_) async => ok());
});
Future<AuthUiHandle> open(
WidgetTester tester, {
String location = '/auth/confirm?token_hash=abc123&type=email',
Locale locale = const Locale('de'),
ThemeMode themeMode = ThemeMode.light,
double? textScale,
Size size = const Size(400, 800),
bool mobileWeb = false,
}) => pumpAuthRoute(
tester,
repository,
location: location,
locale: locale,
themeMode: themeMode,
textScale: textScale,
size: size,
mobileWeb: mobileWeb,
);
Future<void> tapSubmit(WidgetTester tester) async {
await tester.ensureVisible(find.byKey(const Key('email_link_submit')));
await tester.tap(find.byKey(const Key('email_link_submit')));
await tester.pumpAndSettle();
}
group('KTO-01 Kriterium 1, 8: nothing happens before the click (E3)', () {
testWidgets('opening the page does not redeem the link', (tester) async {
await open(tester);
// Title and button share the text "E-Mail-Adresse bestätigen".
expect(find.text(_de.authConfirmTitle), findsNWidgets(2));
expect(find.text(_de.authConfirmBody), findsOneWidget);
verifyNever(() => repository.verifyEmailLink(any(), any()));
});
testWidgets('type=signup is accepted as confirmation', (tester) async {
await open(
tester,
location: '/auth/confirm?token_hash=abc123&type=signup',
);
await tapSubmit(tester);
verify(
() => repository.verifyEmailLink('abc123', EmailLinkType.confirmation),
).called(1);
});
});
group('KTO-01 Kriterium 1: confirmation', () {
testWidgets('the click redeems the link and shows the success', (
tester,
) async {
await open(tester);
await tapSubmit(tester);
verify(
() => repository.verifyEmailLink('abc123', EmailLinkType.confirmation),
).called(1);
expect(find.text(_de.authConfirmSuccess), findsOneWidget);
expect(find.text(_de.authConfirmSuccessMobileWeb), findsNothing);
expect(find.byKey(const Key('email_link_submit')), findsNothing);
});
testWidgets('mobile web adds the hint about the app', (tester) async {
await open(tester, mobileWeb: true);
await tapSubmit(tester);
expect(
find.text(
'${_de.authConfirmSuccess} ${_de.authConfirmSuccessMobileWeb}',
),
findsOneWidget,
);
});
testWidgets('continue leads to the start page', (tester) async {
final (:container, :router) = await open(tester);
await tapSubmit(tester);
await tester.tap(find.byKey(const Key('email_link_continue')));
await tester.pumpAndSettle();
expect(currentPath(router), '/');
});
testWidgets('while redeeming the button is locked', (tester) async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.verifyEmailLink(any(), any()))
.thenAnswer((_) => completer.future);
await open(tester);
await tester.tap(find.byKey(const Key('email_link_submit')));
await tester.pump();
await tester.tap(find.byKey(const Key('email_link_submit')));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
verify(() => repository.verifyEmailLink(any(), any())).called(1);
completer.complete(ok());
await tester.pumpAndSettle();
});
});
group('KTO-01 Kriterium 8: invalid links', () {
testWidgets('an expired or used link shows the error and a way out', (
tester,
) async {
when(() => repository.verifyEmailLink(any(), any())).thenAnswer(
(_) async => failed(const AuthFailure.linkInvalidOrExpired()),
);
final (:container, :router) = await open(tester);
await tapSubmit(tester);
expect(find.text(_de.authErrorLinkInvalidOrExpired), findsOneWidget);
expect(find.byKey(const Key('email_link_submit')), findsNothing);
await tester.tap(find.text(_de.authActionRequestNewLink));
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
});
for (final location in [
'/auth/confirm',
'/auth/confirm?type=email',
'/auth/confirm?token_hash=abc',
'/auth/confirm?token_hash=abc&type=magiclink',
]) {
testWidgets('missing or unknown parameters: $location', (tester) async {
await open(tester, location: location);
expect(find.text(_de.authErrorLinkInvalidOrExpired), findsOneWidget);
expect(find.byKey(const Key('email_link_submit')), findsNothing);
verifyNever(() => repository.verifyEmailLink(any(), any()));
});
}
testWidgets('Zustände Kriterium 2: offline allows another try', (
tester,
) async {
when(() => repository.verifyEmailLink(any(), any()))
.thenAnswer((_) async => failed(const AuthFailure.network()));
await open(tester);
await tapSubmit(tester);
expect(find.text(_de.authErrorNetwork), findsOneWidget);
expect(find.byKey(const Key('email_link_submit')), findsOneWidget);
when(() => repository.verifyEmailLink(any(), any()))
.thenAnswer((_) async => ok());
await tapSubmit(tester);
expect(find.text(_de.authConfirmSuccess), findsOneWidget);
});
});
testWidgets('recovery links show their own texts and redeem as recovery', (
tester,
) async {
await open(
tester,
location: '/auth/confirm?token_hash=abc123&type=recovery',
);
expect(find.text(_de.authRecoveryTitle), findsOneWidget);
expect(find.text(_de.authRecoverySubmit), findsOneWidget);
await tapSubmit(tester);
verify(() => repository.verifyEmailLink('abc123', EmailLinkType.recovery))
.called(1);
});
testWidgets('QUE-05 Kriterium 2: english texts', (tester) async {
await open(tester, locale: const Locale('en'));
expect(find.text(_en.authConfirmTitle), findsNWidgets(2));
expect(find.text(_en.authConfirmBody), findsOneWidget);
});
group('QUE-06 Kriterien 1-4: accessibility', () {
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
testWidgets('meets the guidelines ($mode)', (tester) async {
final handle = tester.ensureSemantics();
await open(tester, themeMode: mode);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('the error state meets the guidelines', (tester) async {
final handle = tester.ensureSemantics();
await open(tester, location: '/auth/confirm?token_hash=x&type=bad');
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
testWidgets('text scale 2.0 on a small phone does not overflow', (
tester,
) async {
await open(tester, textScale: 2, size: const Size(320, 640));
await tapSubmit(tester);
expect(tester.takeException(), isNull);
});
});
}

Some files were not shown because too many files have changed in this diff Show more