Merge pull request 'feat(auth): KTO-01 Anmeldung (01b) – LoginScreen (3/4)' (#35) from feature/1-01b-anmeldung into main
All checks were successful
CI / flutter (push) Successful in 1m11s
CI / web-build (push) Successful in 54s
CI / supabase (push) Successful in 3s
CI / supabase-tests (push) Successful in 30s

Reviewed-on: #35
This commit is contained in:
cschlaefke 2026-11-03 08:33:35 +00:00
commit 86bcd25f30
23 changed files with 1752 additions and 24 deletions

View file

@ -1,7 +1,7 @@
---
name: docs-writer
description: Technischer Redakteur. Hält die Projektdoku in Outline (Collection Shuffle) synchron mit Repo, Specs und ADRs, schreibt Changelog und Release Notes. Einsetzen nach gemergten Features, bei Releases und auf Wunsch.
tools: Read, Grep, Glob, Write, Edit, mcp__outline__*, mcp__forgejo__*
tools: Read, Grep, Glob, Write, Edit, mcp__outline__*, mcp__3f9ac702-fa59-4ed4-aa26-cece4fcf6d21__*, mcp__forgejo__*
model: haiku
---

View file

@ -36,3 +36,8 @@ String weakPasswordMessage(
};
return texts.isEmpty ? l10n.authErrorWeakPasswordCharacters : texts.join(' ');
}
/// Failures the user can fix by simply trying again (offline, unexpected).
extension AuthFailureRetry on AuthFailure {
bool get isRetryable => this is NetworkFailure || this is Unexpected;
}

View file

@ -3,12 +3,17 @@ 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/login/login_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.
/// Routes of the auth pages (Spec #1 § 5.3). Forgot and reset password are
/// placeholders until 01c.
final List<RouteBase> authRoutes = [
authPlaceholderRoute(AppRoutes.login, 'login'),
GoRoute(
path: AppRoutes.login,
builder: (context, state) =>
LoginScreen(from: state.uri.queryParameters[AppRoutes.fromParam]),
),
GoRoute(
path: AppRoutes.register,
builder: (context, state) => const RegisterScreen(),

View file

@ -0,0 +1,93 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/routing/app_routes.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 'package:shuffle/features/auth/presentation/login/login_state.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
part 'login_controller.g.dart';
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
@riverpod
class LoginController extends _$LoginController {
@override
LoginState build() => const LoginState();
void emailChanged(String value) {
state = state.copyWith(email: value);
}
void passwordChanged(String value) {
state = state.copyWith(password: value);
}
/// Field left: show its error.
void fieldBlurred(LoginField field) {
state = state.copyWith(visibleFields: {...state.visibleFields, field});
}
/// Validates and signs in. Returns the path to continue to on success
/// (the validated `from` target, otherwise the start page), `null` if the
/// user stays on the screen. A second call while one runs does nothing.
Future<String?> submit({String? from}) async {
if (state.isSubmitting) {
return null;
}
state = state.copyWith(
status: LoginStatus.submitting,
visibleFields: LoginField.values.toSet(),
bannerFailure: null,
unconfirmedEmail: null,
focusField: null,
);
// A new attempt starts without the result of an earlier resend.
ref.invalidate(resendControllerProvider);
final invalid = state.firstInvalidField;
if (invalid != null) {
state = state.copyWith(
status: LoginStatus.editing,
focusField: invalid,
focusRequest: state.focusRequest + 1,
);
return null;
}
final email = EmailFormat.normalize(state.email);
final result = await ref
.read(authRepositoryProvider)
.signIn(email, state.password);
final failure = result.errorOrNull;
if (failure == null) {
// Success: the target is returned even if the screen is already gone
// (the router redirect can remove it before this future completes), so
// the caller can still finish the autofill context. The status stays
// `submitting`, the form is not reused after a successful sign-in.
return safeFromPath(from) ?? AppRoutes.home;
}
if (!ref.mounted) {
return null;
}
state = state.copyWith(
status: LoginStatus.editing,
bannerFailure: failure,
unconfirmedEmail: failure is EmailNotConfirmed ? email : null,
);
return null;
}
/// "Resend confirmation email" after `emailNotConfirmed` (Kriterium 6).
/// The password was right, so a mail-limit answer is shown (F12).
Future<void> resendConfirmation() async {
final email = state.unconfirmedEmail;
if (email == null) {
return;
}
await ref
.read(resendControllerProvider.notifier)
.resend(email, origin: ResendOrigin.login);
}
}

View file

@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
@ProviderFor(LoginController)
final loginControllerProvider = LoginControllerProvider._();
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
final class LoginControllerProvider
extends $NotifierProvider<LoginController, LoginState> {
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
LoginControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'loginControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$loginControllerHash();
@$internal
@override
LoginController create() => LoginController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(LoginState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<LoginState>(value),
);
}
}
String _$loginControllerHash() => r'693066c260db8abe8f70d04b88b216b2e0411579';
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
abstract class _$LoginController extends $Notifier<LoginState> {
LoginState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<LoginState, LoginState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<LoginState, LoginState>,
LoginState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -0,0 +1,246 @@
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_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/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
import 'package:shuffle/features/auth/presentation/login/login_controller.dart';
import 'package:shuffle/features/auth/presentation/login/login_state.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// Sign-in (`/login`, KTO-01 Kriterien 5, 6 und 7). Only shows state and
/// forwards input to the [LoginController]. [from] is the unchecked `from`
/// query parameter; the controller validates it.
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({this.from, super.key});
final String? from;
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
@override
void initState() {
super.initState();
_emailFocus.addListener(
() => _onFocusChange(_emailFocus, LoginField.email),
);
_passwordFocus.addListener(
() => _onFocusChange(_passwordFocus, LoginField.password),
);
}
void _onFocusChange(FocusNode node, LoginField field) {
if (!node.hasFocus) {
ref.read(loginControllerProvider.notifier).fieldBlurred(field);
}
}
@override
void dispose() {
_email.dispose();
_password.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
Future<void> _submit() async {
final target = await ref
.read(loginControllerProvider.notifier)
.submit(from: widget.from);
if (target == null) {
return;
}
// Also when the router redirect (status signedIn) already removed this
// screen: the password manager must still learn that sign-in worked.
TextInput.finishAutofillContext();
if (!mounted) {
return;
}
context.go(target);
}
void _requestFocus(LoginField? field) {
switch (field) {
case LoginField.email:
_emailFocus.requestFocus();
case LoginField.password:
_passwordFocus.requestFocus();
case null:
break;
}
}
@override
Widget build(BuildContext context) {
ref.listen(loginControllerProvider, (previous, next) {
if (previous?.focusRequest != next.focusRequest) {
_requestFocus(next.focusField);
}
});
final state = ref.watch(loginControllerProvider);
final resend = ref.watch(resendControllerProvider);
final controller = ref.read(loginControllerProvider.notifier);
final l10n = context.l10n;
final busy = state.isSubmitting;
return AppFormScaffold(
key: const Key('login_screen'),
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthScreenTitle(l10n.authLoginTitle),
if (state.bannerFailure case final failure?) ...[
const SizedBox(height: AppSpacing.lg),
if (state.showsNotConfirmed)
AppBanner(
key: const Key('login_not_confirmed_banner'),
type: AppBannerType.info,
typeLabel: l10n.authBannerTypeInfo,
message: l10n.authErrorEmailNotConfirmed,
actionLabel: resend.isSending
? null
: l10n.authActionResendConfirmation,
onAction: resend.isSending
? null
: () => unawaited(controller.resendConfirmation()),
)
else
_FailureBanner(
key: const Key('login_banner'),
failure: failure,
onRetry: busy ? null : _submit,
),
],
if (state.showsNotConfirmed && resend.sent) ...[
const SizedBox(height: AppSpacing.md),
AppBanner(
key: const Key('login_resent_banner'),
type: AppBannerType.info,
typeLabel: l10n.authBannerTypeInfo,
message: l10n.authCheckEmailResent,
),
],
if ((state.showsNotConfirmed, resend.failure) case (
true,
final resendFailure?,
)) ...[
const SizedBox(height: AppSpacing.md),
AppBanner(
key: const Key('login_resend_error_banner'),
type: AppBannerType.error,
typeLabel: l10n.authBannerTypeError,
message: authFailureMessage(l10n, resendFailure),
),
],
const SizedBox(height: AppSpacing.xl),
AppTextField(
key: const Key('login_email'),
label: l10n.authFieldEmailLabel,
controller: _email,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
autocorrect: false,
enableSuggestions: false,
enabled: !busy,
errorText: state.shownEmailError
? l10n.authErrorEmailInvalid
: null,
onChanged: controller.emailChanged,
),
const SizedBox(height: AppSpacing.lg),
AppPasswordField(
key: const Key('login_password'),
label: l10n.authFieldPasswordLabel,
showPasswordLabel: l10n.authPasswordShow,
hidePasswordLabel: l10n.authPasswordHide,
controller: _password,
focusNode: _passwordFocus,
textInputAction: TextInputAction.done,
enabled: !busy,
errorText: state.shownPasswordRequired
? l10n.authPasswordRequired
: null,
onChanged: controller.passwordChanged,
onSubmitted: (_) {
if (!busy) {
unawaited(_submit());
}
},
),
const SizedBox(height: AppSpacing.xl),
AppPrimaryButton(
key: const Key('login_submit'),
label: l10n.authLoginSubmit,
loadingLabel: l10n.authSubmitting,
isLoading: busy,
onPressed: _submit,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
key: const Key('login_forgot_link'),
onPressed: busy
? null
: () => context.go(AppRoutes.forgotPassword),
child: Text(l10n.authLoginForgotPassword),
),
TextButton(
key: const Key('login_register_link'),
onPressed: busy ? null : () => context.go(AppRoutes.register),
child: Text(l10n.authLoginCreateAccount),
),
],
),
),
);
}
}
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.isRetryable;
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,75 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
part 'login_state.freezed.dart';
/// Form fields of the sign-in, in reading order.
enum LoginField { email, password }
enum LoginStatus { editing, submitting }
/// Values, visibility of field errors and submit state of the sign-in form.
///
/// Credential failures are never attached to a field (Kriterium 7): they
/// show as [bannerFailure] above the form.
@freezed
abstract class LoginState with _$LoginState {
const factory LoginState({
@Default('') String email,
@Default('') String password,
@Default(<LoginField>{}) Set<LoginField> visibleFields,
@Default(LoginStatus.editing) LoginStatus status,
/// Failure of the last attempt, shown as banner.
AuthFailure? bannerFailure,
/// Address of the last attempt that ended in `emailNotConfirmed`. The
/// resend action uses this one, not whatever is in the field now.
String? unconfirmedEmail,
/// Field that should receive focus after a failed submit.
LoginField? focusField,
/// Incremented whenever [focusField] is set.
@Default(0) int focusRequest,
}) = _LoginState;
const LoginState._();
/// No credentials in logs or error reports.
@override
String toString() =>
'LoginState(email: <redacted>, password: <redacted>, status: $status, '
'bannerFailure: $bannerFailure)';
bool get isSubmitting => status == LoginStatus.submitting;
EmailError? get emailError => EmailFormat.validate(email);
bool get passwordMissing => password.isEmpty;
bool hasError(LoginField field) => switch (field) {
LoginField.email => emailError != null,
LoginField.password => passwordMissing,
};
LoginField? get firstInvalidField {
for (final field in LoginField.values) {
if (hasError(field)) {
return field;
}
}
return null;
}
bool isVisible(LoginField field) => visibleFields.contains(field);
bool get shownEmailError => isVisible(LoginField.email) && emailError != null;
bool get shownPasswordRequired =>
isVisible(LoginField.password) && passwordMissing;
bool get showsNotConfirmed =>
bannerFailure is EmailNotConfirmed && unconfirmedEmail != null;
}

View file

@ -0,0 +1,331 @@
// 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 'login_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$LoginState {
String get email; String get password; Set<LoginField> get visibleFields; LoginStatus get status;/// Failure of the last attempt, shown as banner.
AuthFailure? get bannerFailure;/// Address of the last attempt that ended in `emailNotConfirmed`. The
/// resend action uses this one, not whatever is in the field now.
String? get unconfirmedEmail;/// Field that should receive focus after a failed submit.
LoginField? get focusField;/// Incremented whenever [focusField] is set.
int get focusRequest;
/// Create a copy of LoginState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$LoginStateCopyWith<LoginState> get copyWith => _$LoginStateCopyWithImpl<LoginState>(this as LoginState, _$identity);
@override
bool operator ==(Object other) {
final _this = this as LoginState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginState&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.password, _this.password) || other.password == _this.password)&&const DeepCollectionEquality().equals(other.visibleFields, _this.visibleFields)&&(identical(other.status, _this.status) || other.status == _this.status)&&(identical(other.bannerFailure, _this.bannerFailure) || other.bannerFailure == _this.bannerFailure)&&(identical(other.unconfirmedEmail, _this.unconfirmedEmail) || other.unconfirmedEmail == _this.unconfirmedEmail)&&(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 LoginState;
return Object.hash(runtimeType,_this.email,_this.password,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.bannerFailure,_this.unconfirmedEmail,_this.focusField,_this.focusRequest);
}
}
/// @nodoc
abstract mixin class $LoginStateCopyWith<$Res> {
factory $LoginStateCopyWith(LoginState value, $Res Function(LoginState) _then) = _$LoginStateCopyWithImpl;
@useResult
$Res call({
String email, String password, Set<LoginField> visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest
});
$AuthFailureCopyWith<$Res>? get bannerFailure;
}
/// @nodoc
class _$LoginStateCopyWithImpl<$Res>
implements $LoginStateCopyWith<$Res> {
_$LoginStateCopyWithImpl(this._self, this._then);
final LoginState _self;
final $Res Function(LoginState) _then;
/// Create a copy of LoginState
/// 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? visibleFields = null,Object? status = null,Object? bannerFailure = freezed,Object? unconfirmedEmail = freezed,Object? focusField = freezed,Object? focusRequest = null,}) {
return _then(LoginState(
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,visibleFields: null == visibleFields ? _self.visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable
as Set<LoginField>,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as LoginStatus,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,unconfirmedEmail: freezed == unconfirmedEmail ? _self.unconfirmedEmail : unconfirmedEmail // ignore: cast_nullable_to_non_nullable
as String?,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable
as LoginField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of LoginState
/// 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 [LoginState].
extension LoginStatePatterns on LoginState {
/// 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( _LoginState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _LoginState() 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( _LoginState value) $default,){
final _that = this;
switch (_that) {
case _LoginState():
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( _LoginState value)? $default,){
final _that = this;
switch (_that) {
case _LoginState() 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, Set<LoginField> visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _LoginState() when $default != null:
return $default(_that.email,_that.password,_that.visibleFields,_that.status,_that.bannerFailure,_that.unconfirmedEmail,_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, Set<LoginField> visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest) $default,) {final _that = this;
switch (_that) {
case _LoginState():
return $default(_that.email,_that.password,_that.visibleFields,_that.status,_that.bannerFailure,_that.unconfirmedEmail,_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, Set<LoginField> visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest)? $default,) {final _that = this;
switch (_that) {
case _LoginState() when $default != null:
return $default(_that.email,_that.password,_that.visibleFields,_that.status,_that.bannerFailure,_that.unconfirmedEmail,_that.focusField,_that.focusRequest);case _:
return null;
}
}
}
/// @nodoc
class _LoginState extends LoginState {
const _LoginState({this.email = '', this.password = '', Set<LoginField> visibleFields = const <LoginField>{}, this.status = LoginStatus.editing, this.bannerFailure, this.unconfirmedEmail, this.focusField, this.focusRequest = 0}): _visibleFields = visibleFields,super._();
@override@JsonKey() final String email;
@override@JsonKey() final String password;
final Set<LoginField> _visibleFields;
@override@JsonKey() Set<LoginField> get visibleFields {
if (_visibleFields is EqualUnmodifiableSetView) return _visibleFields;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_visibleFields);
}
@override@JsonKey() final LoginStatus status;
/// Failure of the last attempt, shown as banner.
@override final AuthFailure? bannerFailure;
/// Address of the last attempt that ended in `emailNotConfirmed`. The
/// resend action uses this one, not whatever is in the field now.
@override final String? unconfirmedEmail;
/// Field that should receive focus after a failed submit.
@override final LoginField? focusField;
/// Incremented whenever [focusField] is set.
@override@JsonKey() final int focusRequest;
/// Create a copy of LoginState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$LoginStateCopyWith<_LoginState> get copyWith => __$LoginStateCopyWithImpl<_LoginState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoginState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&const DeepCollectionEquality().equals(other.visibleFields, _visibleFields)&&(identical(other.status, status) || other.status == status)&&(identical(other.bannerFailure, bannerFailure) || other.bannerFailure == bannerFailure)&&(identical(other.unconfirmedEmail, unconfirmedEmail) || other.unconfirmedEmail == unconfirmedEmail)&&(identical(other.focusField, focusField) || other.focusField == focusField)&&(identical(other.focusRequest, focusRequest) || other.focusRequest == focusRequest));
}
@override
int get hashCode {
return Object.hash(runtimeType,email,password,const DeepCollectionEquality().hash(_visibleFields),status,bannerFailure,unconfirmedEmail,focusField,focusRequest);
}
}
/// @nodoc
abstract mixin class _$LoginStateCopyWith<$Res> implements $LoginStateCopyWith<$Res> {
factory _$LoginStateCopyWith(_LoginState value, $Res Function(_LoginState) _then) = __$LoginStateCopyWithImpl;
@override @useResult
$Res call({
String email, String password, Set<LoginField> visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest
});
@override $AuthFailureCopyWith<$Res>? get bannerFailure;
}
/// @nodoc
class __$LoginStateCopyWithImpl<$Res>
implements _$LoginStateCopyWith<$Res> {
__$LoginStateCopyWithImpl(this._self, this._then);
final _LoginState _self;
final $Res Function(_LoginState) _then;
/// Create a copy of LoginState
/// 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? visibleFields = null,Object? status = null,Object? bannerFailure = freezed,Object? unconfirmedEmail = freezed,Object? focusField = freezed,Object? focusRequest = null,}) {
return _then(_LoginState(
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,visibleFields: null == visibleFields ? _self._visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable
as Set<LoginField>,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as LoginStatus,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable
as AuthFailure?,unconfirmedEmail: freezed == unconfirmedEmail ? _self.unconfirmedEmail : unconfirmedEmail // ignore: cast_nullable_to_non_nullable
as String?,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable
as LoginField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of LoginState
/// 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

@ -69,10 +69,21 @@ String? safeFromPath(String? from) {
if (from.startsWith('//') || from.startsWith(r'/\')) {
return null;
}
// Browsers drop tabs and line breaks inside URLs (`/\t/evil` -> `//evil`),
// so control characters are never allowed.
if (RegExp(r'[\x00-\x1f\x7f]').hasMatch(from)) {
return null;
}
final uri = Uri.tryParse(from);
if (uri == null || uri.hasScheme || uri.hasAuthority) {
return null;
}
// Percent-encoded variants (`/%2F%2Fevil`, `/%5Cevil`) must not decode to a
// protocol-relative path either.
final decoded = Uri.decodeFull(uri.path);
if (decoded.startsWith('//') || decoded.startsWith(r'/\')) {
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

View file

@ -319,7 +319,7 @@ class _FailureBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final retryable = failure is NetworkFailure || failure is Unexpected;
final retryable = failure.isRetryable;
return AppBanner(
type: AppBannerType.error,
typeLabel: l10n.authBannerTypeError,

View file

@ -56,6 +56,13 @@ abstract class RegisterState with _$RegisterState {
const RegisterState._();
/// No credentials or personal data in logs or error reports.
@override
String toString() =>
'RegisterState(email: <redacted>, password: <redacted>, '
'displayName: <redacted>, status: $status, '
'bannerFailure: $bannerFailure)';
bool get isSubmitting => status == RegisterStatus.submitting;
EmailError? get emailError => EmailFormat.validate(email);

View file

@ -44,11 +44,6 @@ int get hashCode {
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})';
}
}
@ -301,10 +296,6 @@ 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)';
}
}

View file

@ -47,6 +47,10 @@
"authRegisterTitle": "Konto erstellen",
"authRegisterSubmit": "Konto erstellen",
"authRegisterHaveAccount": "Schon ein Konto? Anmelden",
"authLoginTitle": "Anmelden",
"authLoginSubmit": "Anmelden",
"authLoginForgotPassword": "Passwort vergessen?",
"authLoginCreateAccount": "Konto erstellen",
"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.",

View file

@ -86,6 +86,10 @@
"authRegisterTitle": "Create account",
"authRegisterSubmit": "Create account",
"authRegisterHaveAccount": "Already have an account? Sign in",
"authLoginTitle": "Sign in",
"authLoginSubmit": "Sign in",
"authLoginForgotPassword": "Forgot password?",
"authLoginCreateAccount": "Create account",
"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.",

View file

@ -380,6 +380,30 @@ abstract class AppLocalizations {
/// **'Already have an account? Sign in'**
String get authRegisterHaveAccount;
/// No description provided for @authLoginTitle.
///
/// In en, this message translates to:
/// **'Sign in'**
String get authLoginTitle;
/// No description provided for @authLoginSubmit.
///
/// In en, this message translates to:
/// **'Sign in'**
String get authLoginSubmit;
/// No description provided for @authLoginForgotPassword.
///
/// In en, this message translates to:
/// **'Forgot password?'**
String get authLoginForgotPassword;
/// No description provided for @authLoginCreateAccount.
///
/// In en, this message translates to:
/// **'Create account'**
String get authLoginCreateAccount;
/// No description provided for @authCheckEmailTitle.
///
/// In en, this message translates to:

View file

@ -179,6 +179,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get authRegisterHaveAccount => 'Schon ein Konto? Anmelden';
@override
String get authLoginTitle => 'Anmelden';
@override
String get authLoginSubmit => 'Anmelden';
@override
String get authLoginForgotPassword => 'Passwort vergessen?';
@override
String get authLoginCreateAccount => 'Konto erstellen';
@override
String get authCheckEmailTitle => 'Prüfe dein Postfach';

View file

@ -176,6 +176,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get authRegisterHaveAccount => 'Already have an account? Sign in';
@override
String get authLoginTitle => 'Sign in';
@override
String get authLoginSubmit => 'Sign in';
@override
String get authLoginForgotPassword => 'Forgot password?';
@override
String get authLoginCreateAccount => 'Create account';
@override
String get authCheckEmailTitle => 'Check your inbox';

View file

@ -150,5 +150,13 @@ void main() {
expect(safeFromPath(''), isNull);
expect(safeFromPath('javascript:alert(1)'), isNull);
});
test('rejects encoded and control-character tricks', () {
expect(safeFromPath('/%2F%2Fevil.example'), isNull);
expect(safeFromPath('%2F%2Fevil'), isNull);
expect(safeFromPath('/%5Cevil.example'), isNull);
expect(safeFromPath('/\t/evil.example'), isNull);
expect(safeFromPath('/a\nb'), isNull);
});
});
}

View file

@ -86,7 +86,7 @@ void main() {
expect(currentPath(router), '/login');
expect(currentUri(router).queryParameters['from'], '/private?tab=1');
expect(find.byKey(const Key('auth_placeholder_login')), findsOneWidget);
expect(find.byKey(const Key('login_screen')), findsOneWidget);
});
testWidgets('signing in on /login returns to from', (tester) async {
@ -116,12 +116,12 @@ void main() {
});
testWidgets(
'routes of spec 5.3 exist (01a: real screens, rest placeholders)',
'routes of spec 5.3 exist (01b: login real, rest placeholders)',
(tester) async {
final (container: _, :router) = await start(tester);
for (final (path, key) in [
('/login', 'auth_placeholder_login'),
('/login', 'login_screen'),
('/register', 'register_screen'),
('/register/check-email', 'check_email_screen'),
('/forgot-password', 'auth_placeholder_forgot_password'),

View file

@ -0,0 +1,242 @@
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/presentation/check_email/resend_controller.dart';
import 'package:shuffle/features/auth/presentation/login/login_controller.dart';
import 'package:shuffle/features/auth/presentation/login/login_state.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, loginControllerProvider);
keepAlive(container, resendControllerProvider);
});
LoginController notifier() =>
container.read(loginControllerProvider.notifier);
LoginState state() => container.read(loginControllerProvider);
void stubSignIn(Result<void, AuthFailure> result) =>
when(() => repository.signIn(any(), any()))
.thenAnswer((_) async => result);
void fill([String email = ' Anna@Example.com ', String password = 'pw']) {
notifier()
..emailChanged(email)
..passwordChanged(password);
}
group('KTO-01 Kriterium 5: sign in', () {
test('sends the trimmed address, continues to the start page', () async {
stubSignIn(ok());
fill();
final target = await notifier().submit();
verify(() => repository.signIn('Anna@Example.com', 'pw')).called(1);
expect(target, '/');
// The form is left after success; it is not unlocked again.
expect(state().isSubmitting, isTrue);
expect(state().bannerFailure, isNull);
});
test('continues to a safe from path', () async {
stubSignIn(ok());
fill();
expect(await notifier().submit(from: '/games/7?tab=1'), '/games/7?tab=1');
});
for (final unsafe in [
'https://evil.example/x',
'//evil.example',
r'/\evil.example',
'javascript:alert(1)',
'games',
'/login',
'/register',
'/reset-password',
'/auth/confirm?token_hash=x',
'/%2F%2Fevil.example',
'%2F%2Fevil',
'/\t/evil.example',
'/%5Cevil.example',
'',
]) {
test('ignores the unsafe from "$unsafe"', () async {
stubSignIn(ok());
fill();
expect(await notifier().submit(from: unsafe), '/');
});
}
test('empty fields are not sent and ask for focus', () async {
await notifier().submit();
verifyNever(() => repository.signIn(any(), any()));
expect(state().shownEmailError, isTrue);
expect(state().shownPasswordRequired, isTrue);
expect(state().focusField, LoginField.email);
expect(state().focusRequest, 1);
expect(state().isSubmitting, isFalse);
});
test('an invalid address is not sent', () async {
fill('anna', 'pw');
expect(await notifier().submit(), isNull);
verifyNever(() => repository.signIn(any(), any()));
expect(state().shownEmailError, isTrue);
expect(state().shownPasswordRequired, isFalse);
});
test('blur shows the error of that field only', () {
notifier().fieldBlurred(LoginField.password);
expect(state().shownPasswordRequired, isTrue);
expect(state().shownEmailError, isFalse);
});
test('a second submit while one runs is ignored', () async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.signIn(any(), any()))
.thenAnswer((_) => completer.future);
fill();
final first = notifier().submit();
await Future<void>.delayed(Duration.zero);
expect(state().isSubmitting, isTrue);
expect(await notifier().submit(), isNull);
completer.complete(ok());
await first;
verify(() => repository.signIn(any(), any())).called(1);
});
});
group('no credentials in logs', () {
test('toString masks email and password', () {
fill('anna@example.com', 'geheim-und-lang');
final text = state().toString();
expect(text, isNot(contains('anna@example.com')));
expect(text, isNot(contains('geheim-und-lang')));
expect(text, contains('<redacted>'));
});
});
group('KTO-01 Kriterium 7: failures', () {
test('invalid credentials keep the input and show the failure', () async {
stubSignIn(failed(const AuthFailure.invalidCredentials()));
fill();
expect(await notifier().submit(), isNull);
expect(state().bannerFailure, const AuthFailure.invalidCredentials());
expect(state().email, ' Anna@Example.com ');
expect(state().password, 'pw');
expect(state().shownEmailError, isFalse);
expect(state().shownPasswordRequired, isFalse);
expect(state().showsNotConfirmed, isFalse);
});
for (final failure in const [
AuthFailure.rateLimited(),
AuthFailure.network(),
AuthFailure.unexpected(),
]) {
test('${failure.runtimeType} is shown as banner', () async {
stubSignIn(failed(failure));
fill();
await notifier().submit();
expect(state().bannerFailure, failure);
expect(state().isSubmitting, isFalse);
});
}
test('a new attempt clears the old failure', () async {
stubSignIn(failed(const AuthFailure.invalidCredentials()));
fill();
await notifier().submit();
stubSignIn(ok());
await notifier().submit();
expect(state().bannerFailure, isNull);
});
});
group('KTO-01 Kriterium 6: unconfirmed account', () {
setUp(() {
stubSignIn(failed(const AuthFailure.emailNotConfirmed()));
fill();
});
test('marks the address for resend', () async {
await notifier().submit();
expect(state().showsNotConfirmed, isTrue);
expect(state().unconfirmedEmail, 'Anna@Example.com');
});
test('resend uses the address of the attempt, not the field now', () async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => ok());
await notifier().submit();
notifier().emailChanged('other@example.com');
await notifier().resendConfirmation();
verify(() => repository.resendConfirmation('Anna@Example.com')).called(1);
expect(container.read(resendControllerProvider).sent, isTrue);
});
test('emailSendLimited shows the throttle hint (login origin)', () async {
when(
() => repository.resendConfirmation(any()),
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
await notifier().submit();
await notifier().resendConfirmation();
final resend = container.read(resendControllerProvider);
expect(resend.failure, const AuthFailure.rateLimited());
expect(resend.sent, isFalse);
});
test('resend without a pending attempt does nothing', () async {
await notifier().resendConfirmation();
verifyNever(() => repository.resendConfirmation(any()));
});
test('a new attempt resets the resend result', () async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => ok());
await notifier().submit();
await notifier().resendConfirmation();
expect(container.read(resendControllerProvider).sent, isTrue);
await notifier().submit();
expect(container.read(resendControllerProvider).sent, isFalse);
});
});
}

View file

@ -0,0 +1,557 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.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/presentation/auth_routes.dart';
import 'package:shuffle/l10n/app_localizations_de.dart';
import 'package:shuffle/l10n/app_localizations_en.dart';
import '../../../../helpers/pump_app.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.signIn(any(), any())).thenAnswer((_) async => ok());
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => ok());
});
Future<AuthUiHandle> open(
WidgetTester tester, {
String location = '/login',
Locale locale = const Locale('de'),
ThemeMode themeMode = ThemeMode.light,
double? textScale,
Size size = const Size(400, 800),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final router = GoRouter(
initialLocation: location,
routes: [
...authRoutes,
GoRoute(path: '/', builder: (_, _) => const Text('home-page')),
GoRoute(
path: '/private',
builder: (_, _) => const Text('private-page'),
),
],
);
addTearDown(router.dispose);
final container = await pumpApp(
tester,
router: router,
locale: locale,
themeMode: themeMode,
textScale: textScale,
overrides: authUiOverrides(repository, locale: locale),
);
return (container: container, router: router);
}
Future<void> type(WidgetTester tester, String key, String text) async {
await tester.ensureVisible(fieldOf(key));
await tester.enterText(fieldOf(key), text);
await tester.pump();
}
Future<void> tapKey(WidgetTester tester, String key) async {
final finder = find.byKey(Key(key));
await tester.ensureVisible(finder);
await tester.tap(finder);
await tester.pump();
}
Future<void> fill(WidgetTester tester) async {
await type(tester, 'login_email', 'anna@example.com');
await type(tester, 'login_password', 'geheim-und-lang');
}
Future<void> submit(WidgetTester tester) async {
await tapKey(tester, 'login_submit');
await tester.pumpAndSettle();
}
void stubSignInFailure(AuthFailure failure) =>
when(() => repository.signIn(any(), any()))
.thenAnswer((_) async => failed(failure));
group('KTO-01 Kriterium 5: sign in', () {
testWidgets('signs in and leaves the page', (tester) async {
final (:container, :router) = await open(tester);
await fill(tester);
await submit(tester);
verify(() => repository.signIn('anna@example.com', 'geheim-und-lang'))
.called(1);
expect(currentPath(router), '/');
expect(find.text('home-page'), findsOneWidget);
});
testWidgets('the enter key in the password field submits', (tester) async {
final (:container, :router) = await open(tester);
await fill(tester);
await tester.showKeyboard(fieldOf('login_password'));
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
verify(() => repository.signIn(any(), any())).called(1);
expect(currentPath(router), '/');
});
testWidgets('returns to a valid from path', (tester) async {
final (:container, :router) = await open(
tester,
location: '/login?from=%2Fprivate',
);
await fill(tester);
await submit(tester);
expect(currentPath(router), '/private');
});
for (final from in [
'https://evil.example',
'//evil.example',
'/register',
]) {
testWidgets('ignores the unsafe from "$from"', (tester) async {
final (:container, :router) = await open(
tester,
location: Uri(
path: '/login',
queryParameters: {'from': from},
).toString(),
);
await fill(tester);
await submit(tester);
expect(currentPath(router), '/');
});
}
testWidgets('fields carry autofill hints and keyboard types', (
tester,
) async {
await open(tester);
final email = tester.widget<TextField>(fieldOf('login_email'));
expect(email.autofillHints, contains(AutofillHints.username));
expect(email.autofillHints, contains(AutofillHints.email));
expect(email.keyboardType, TextInputType.emailAddress);
expect(email.textInputAction, TextInputAction.next);
final password = tester.widget<TextField>(fieldOf('login_password'));
expect(password.autofillHints, contains(AutofillHints.password));
expect(
password.autofillHints,
isNot(contains(AutofillHints.newPassword)),
);
expect(password.obscureText, isTrue);
expect(password.textInputAction, TextInputAction.done);
});
testWidgets('empty fields show errors at the fields and send nothing', (
tester,
) async {
await open(tester);
await submit(tester);
expect(find.text(_de.authErrorEmailInvalid), findsOneWidget);
expect(find.text(_de.authPasswordRequired), findsOneWidget);
verifyNever(() => repository.signIn(any(), any()));
});
testWidgets('double tap sends once and locks the form', (tester) async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.signIn(any(), any()))
.thenAnswer((_) => completer.future);
await open(tester);
await fill(tester);
await tapKey(tester, 'login_submit');
await tester.tap(find.byKey(const Key('login_submit')));
await tester.pump();
expect(tester.widget<TextField>(fieldOf('login_email')).enabled, isFalse);
expect(
tester.widget<TextField>(fieldOf('login_password')).enabled,
isFalse,
);
completer.complete(ok());
await tester.pumpAndSettle();
verify(() => repository.signIn(any(), any())).called(1);
});
testWidgets('links lead to register and forgot password', (tester) async {
final (:container, :router) = await open(tester);
await tapKey(tester, 'login_register_link');
await tester.pumpAndSettle();
expect(currentPath(router), '/register');
router.go('/login');
await tester.pumpAndSettle();
await tapKey(tester, 'login_forgot_link');
await tester.pumpAndSettle();
expect(currentPath(router), '/forgot-password');
});
});
group('KTO-01 Kriterium 7: generic failure', () {
testWidgets('invalid credentials: one banner, no field error, input kept', (
tester,
) async {
stubSignInFailure(const AuthFailure.invalidCredentials());
await open(tester);
await fill(tester);
await submit(tester);
expect(find.text(_de.authErrorInvalidCredentials), findsOneWidget);
expect(find.byKey(const Key('login_banner')), findsOneWidget);
expect(find.text(_de.authErrorEmailInvalid), findsNothing);
expect(find.text(_de.authPasswordRequired), findsNothing);
expect(
tester.widget<TextField>(fieldOf('login_email')).controller!.text,
'anna@example.com',
);
expect(
tester.widget<TextField>(fieldOf('login_password')).controller!.text,
'geheim-und-lang',
);
});
testWidgets(
'wrong email and wrong password show exactly the same screen text',
(tester) async {
// Screen level only: the repository mock returns invalidCredentials
// for both cases, so this proves the screen adds nothing that tells
// them apart. The real proof that an unknown address and a wrong
// password yield the same `invalid_credentials` answer is the API
// E2E test (T3).
stubSignInFailure(const AuthFailure.invalidCredentials());
final handle = tester.ensureSemantics();
await open(tester);
await type(tester, 'login_email', 'unknown@example.com');
await type(tester, 'login_password', 'irgendein-passwort');
await submit(tester);
final wrongEmail = visibleTexts(tester);
await type(tester, 'login_email', 'anna@example.com');
await type(tester, 'login_password', 'falsches-passwort');
await submit(tester);
final wrongPassword = visibleTexts(tester);
expect(wrongEmail, wrongPassword);
// Fixed wording, independent of the l10n getter.
const fixed = 'E-Mail-Adresse oder Passwort ist nicht korrekt.';
expect(wrongEmail, contains(fixed));
expect(_de.authErrorInvalidCredentials, fixed);
expect(find.text(fixed), findsOneWidget);
expect(find.bySemanticsLabel('Fehler: $fixed'), findsOneWidget);
handle.dispose();
},
);
testWidgets('throttling (429) shows the throttle hint', (tester) async {
stubSignInFailure(const AuthFailure.rateLimited());
await open(tester);
await fill(tester);
await submit(tester);
expect(find.text(_de.authErrorRateLimited), findsOneWidget);
expect(find.text(_de.authActionRetry), findsNothing);
});
testWidgets('offline: shows the hint, keeps the input, can retry', (
tester,
) async {
var calls = 0;
when(() => repository.signIn(any(), any())).thenAnswer((_) async {
calls++;
return calls == 1 ? failed(const AuthFailure.network()) : ok();
});
final (:container, :router) = await open(tester);
await fill(tester);
await submit(tester);
expect(find.text(_de.authErrorNetwork), findsOneWidget);
expect(
tester.widget<TextField>(fieldOf('login_email')).controller!.text,
'anna@example.com',
);
await tester.tap(find.text(_de.authActionRetry));
await tester.pumpAndSettle();
verify(() => repository.signIn(any(), any())).called(2);
expect(currentPath(router), '/');
});
});
group('KTO-01 Kriterium 6: unconfirmed account', () {
setUp(() => stubSignInFailure(const AuthFailure.emailNotConfirmed()));
testWidgets('shows the hint and resends the confirmation', (tester) async {
await open(tester);
await fill(tester);
await submit(tester);
expect(find.text(_de.authErrorEmailNotConfirmed), findsOneWidget);
expect(find.text(_de.authErrorInvalidCredentials), findsNothing);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
verify(() => repository.resendConfirmation('anna@example.com')).called(1);
expect(find.byKey(const Key('login_resent_banner')), findsOneWidget);
expect(find.text(_de.authCheckEmailResent), findsOneWidget);
});
testWidgets('emailSendLimited shows the throttle hint, not success', (
tester,
) async {
when(
() => repository.resendConfirmation(any()),
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
expect(find.text(_de.authErrorRateLimited), findsOneWidget);
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
});
testWidgets('the resend action is locked while sending', (tester) async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) => completer.future);
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pump();
expect(find.text(_de.authActionResendConfirmation), findsNothing);
completer.complete(ok());
await tester.pumpAndSettle();
verify(() => repository.resendConfirmation(any())).called(1);
});
testWidgets('offline resend shows the offline hint', (tester) async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.network()));
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
expect(find.text(_de.authErrorNetwork), findsOneWidget);
});
});
group('KTO-01 Kriterium 5: autofill context', () {
// Only calls that save the credentials (`shouldSave: true`).
int finishCalls(WidgetTester tester) => tester.testTextInput.log
.where(
(call) =>
call.method == 'TextInput.finishAutofillContext' &&
call.arguments == true,
)
.length;
testWidgets('is finished after a successful sign-in', (tester) async {
await open(tester);
await fill(tester);
tester.testTextInput.log.clear();
await submit(tester);
// Our explicit call, plus the AutofillGroup committing on disposal
// when the screen is replaced.
expect(finishCalls(tester), greaterThanOrEqualTo(1));
});
testWidgets('is finished even if the router already removed the screen', (
tester,
) async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.signIn(any(), any()))
.thenAnswer((_) => completer.future);
final (:container, :router) = await open(tester);
await fill(tester);
await tapKey(tester, 'login_submit');
// The status change makes the router redirect away before the
// sign-in future completes.
router.go('/private');
await tester.pumpAndSettle();
expect(find.byKey(const Key('login_screen')), findsNothing);
tester.testTextInput.log.clear();
completer.complete(ok());
await tester.pumpAndSettle();
expect(finishCalls(tester), 1);
expect(currentPath(router), '/private');
});
testWidgets('is not finished after a failed sign-in', (tester) async {
stubSignInFailure(const AuthFailure.invalidCredentials());
await open(tester);
await fill(tester);
tester.testTextInput.log.clear();
await submit(tester);
expect(finishCalls(tester), 0);
});
});
group('Zustände Kriterium 3: banners and focus', () {
testWidgets('unexpected error shows the banner with retry', (tester) async {
stubSignInFailure(const AuthFailure.unexpected());
await open(tester);
await fill(tester);
await submit(tester);
expect(find.text(_de.authErrorUnexpected), findsOneWidget);
expect(find.text(_de.authActionRetry), findsOneWidget);
stubSignInFailure(const AuthFailure.unexpected());
await tester.tap(find.text(_de.authActionRetry));
await tester.pumpAndSettle();
verify(() => repository.signIn(any(), any())).called(2);
});
testWidgets('a failed submit focuses the first invalid field', (
tester,
) async {
await open(tester);
await submit(tester);
expect(
tester.widget<TextField>(fieldOf('login_email')).focusNode!.hasFocus,
isTrue,
);
await type(tester, 'login_email', 'anna@example.com');
await submit(tester);
expect(
tester.widget<TextField>(fieldOf('login_password')).focusNode!.hasFocus,
isTrue,
);
});
});
group('QUE-05 Kriterium 2: de and en', () {
testWidgets('english texts', (tester) async {
await open(tester, locale: const Locale('en'));
expect(find.text(_en.authLoginTitle), findsWidgets);
expect(find.text(_en.authLoginForgotPassword), findsOneWidget);
expect(find.text(_en.authLoginCreateAccount), findsOneWidget);
expect(find.text(_de.authLoginForgotPassword), findsNothing);
});
});
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();
stubSignInFailure(const AuthFailure.emailNotConfirmed());
await open(tester, themeMode: mode);
await fill(tester);
await submit(tester);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
testWidgets('error banner meets the contrast guideline ($mode)', (
tester,
) async {
final handle = tester.ensureSemantics();
stubSignInFailure(const AuthFailure.invalidCredentials());
await open(tester, themeMode: mode);
await fill(tester);
await submit(tester);
expect(find.byKey(const Key('login_banner')), findsOneWidget);
await expectLater(tester, meetsGuideline(textContrastGuideline));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
handle.dispose();
});
}
testWidgets('the title is a heading and the banner a live region', (
tester,
) async {
final handle = tester.ensureSemantics();
stubSignInFailure(const AuthFailure.invalidCredentials());
await open(tester);
await fill(tester);
await submit(tester);
expect(
find.bySemanticsLabel(
'Fehler: E-Mail-Adresse oder Passwort ist nicht korrekt.',
),
findsOneWidget,
);
expect(
tester.getSemantics(find.text(_de.authLoginTitle).first),
matchesSemantics(label: _de.authLoginTitle, isHeader: true),
);
handle.dispose();
});
testWidgets('text scale 2.0 on a small phone does not overflow', (
tester,
) async {
stubSignInFailure(const AuthFailure.emailNotConfirmed());
await open(tester, textScale: 2, size: const Size(320, 640));
await fill(tester);
await submit(tester);
expect(tester.takeException(), isNull);
});
testWidgets('wide screens show a centered card', (tester) async {
await open(tester, size: const Size(1200, 900));
final card = tester.getRect(find.byType(Card).first);
expect(card.width, lessThanOrEqualTo(480));
expect(card.center.dx, closeTo(600, 1));
});
});
}

View file

@ -71,6 +71,24 @@ void main() {
..termsAcceptedChanged(value: true);
}
group('no personal data in logs', () {
test('toString masks email, password and display name', () {
final container = makeContainer();
fillValid(
container.read(registerControllerProvider.notifier),
email: 'anna@example.com',
name: 'Anna Geheim',
);
final text = container.read(registerControllerProvider).toString();
expect(text, isNot(contains('anna@example.com')));
expect(text, isNot(contains('geheim-und-lang')));
expect(text, isNot(contains('Anna Geheim')));
expect(text, contains('<redacted>'));
});
});
group('KTO-01 Kriterium 1: submit', () {
test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async {
final container = makeContainer(locale: const Locale('en'));

View file

@ -57,19 +57,35 @@ Der Link ist 1 Stunde gültig. Die E-Mail kann einige Minuten dauern. Schau auc
<Zurück zur Anmeldung> (Modus Reset: <Andere E-Mail-Adresse eingeben> -> /forgot-password)
```
### LoginScreen (`/login`)
### LoginScreen (`/login`, umgesetzt in 01b)
```
Anmelden
! Fehler-Banner: authInvalidCredentials | rateLimited | network (über dem Formular, nie am Feld)
! Info-Banner emailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden"
E-Mail [ ]
Passwort [ ••••• (Auge) ]
! Fehler-Banner: authErrorInvalidCredentials | authErrorRateLimited | authErrorNetwork (+ "Erneut versuchen")
! Info-Banner authErrorEmailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden"
! Info-Banner "Wir haben die E-Mail erneut gesendet ..." | Fehler-Banner (authErrorRateLimited / authErrorNetwork) nach dem Resend
E-Mail [ ] Fehler (nur lokal): authErrorEmailInvalid
Passwort [ ••••• (Auge) ] Fehler (nur lokal): authPasswordRequired
( Anmelden )
<Passwort vergessen?>
<Konto erstellen>
<Passwort vergessen?> -> /forgot-password
<Konto erstellen> -> /register
```
Umsetzungsentscheidungen:
- Falsche Zugangsdaten stehen nie am Feld, sondern nur im Banner; Text für falsche E-Mail und falsches Passwort
ist identisch (Krit. 7, Test vergleicht alle sichtbaren Texte). Felder zeigen nur lokale Fehler (leer / E-Mail-Syntax).
- Bei `emailNotConfirmed` merkt sich der Controller die E-Mail des Versuchs; "erneut senden" nutzt diese Adresse
(`ResendController`, `ResendOrigin.login`), nicht den aktuellen Feldinhalt. Dort wird `emailSendLimited` als
Drosselungshinweis gezeigt (F12). Während des Sendens ist die Aktion ausgeblendet (kein Doppelversand).
- Ein neuer Anmeldeversuch setzt das Resend-Ergebnis zurück.
- Erfolg: `context.go(<from oder />)`. `from` wird mit `safeFromPath` geprüft (nur interne Pfade, keine Auth-Seiten,
kein `//`, `/\` oder Schema); sonst Startseite. Zusätzlich leitet der Router bei Status `signedIn` von `/login` um.
- Autofill: `AutofillGroup`, E-Mail `username` + `email`, Passwort `password`; `TextInput.finishAutofillContext()` bei Erfolg.
- Neue l10n-Schlüssel: `authLoginTitle`, `authLoginSubmit`, `authLoginForgotPassword`, `authLoginCreateAccount`.
- Widget-Keys: `login_screen`, `login_email`, `login_password`, `login_submit`, `login_banner`,
`login_not_confirmed_banner`, `login_resent_banner`, `login_resend_error_banner`, `login_forgot_link`, `login_register_link`.
### ForgotPasswordScreen (`/forgot-password`)
```