diff --git a/app/lib/features/auth/presentation/auth_routes.dart b/app/lib/features/auth/presentation/auth_routes.dart index bee11e0..f6fe2c7 100644 --- a/app/lib/features/auth/presentation/auth_routes.dart +++ b/app/lib/features/auth/presentation/auth_routes.dart @@ -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 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(), diff --git a/app/lib/features/auth/presentation/login/login_controller.dart b/app/lib/features/auth/presentation/login/login_controller.dart new file mode 100644 index 0000000..abd79f4 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_controller.dart @@ -0,0 +1,91 @@ +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 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); + if (!ref.mounted) { + return null; + } + + final failure = result.errorOrNull; + if (failure == null) { + state = state.copyWith(status: LoginStatus.editing); + return safeFromPath(from) ?? AppRoutes.home; + } + 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 resendConfirmation() async { + final email = state.unconfirmedEmail; + if (email == null) { + return; + } + await ref + .read(resendControllerProvider.notifier) + .resend(email, origin: ResendOrigin.login); + } +} diff --git a/app/lib/features/auth/presentation/login/login_controller.g.dart b/app/lib/features/auth/presentation/login/login_controller.g.dart new file mode 100644 index 0000000..09313c7 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_controller.g.dart @@ -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 { + /// 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(value), + ); + } +} + +String _$loginControllerHash() => r'f8c37f2b7b942c8cfffbf8b61f3296872b5bcb70'; + +/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7). + +abstract class _$LoginController extends $Notifier { + LoginState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + LoginState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/login/login_screen.dart b/app/lib/features/auth/presentation/login/login_screen.dart new file mode 100644 index 0000000..f5d9e2c --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_screen.dart @@ -0,0 +1,238 @@ +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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + 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 _submit() async { + final target = await ref + .read(loginControllerProvider.notifier) + .submit(from: widget.from); + if (!mounted || target == null) { + return; + } + TextInput.finishAutofillContext(); + 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 != null) ...[ + const SizedBox(height: AppSpacing.md), + AppBanner( + key: const Key('login_resend_error_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, resend.failure!), + ), + ], + 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 is NetworkFailure || failure is Unexpected; + return AppBanner( + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + actionLabel: retryable && onRetry != null ? l10n.authActionRetry : null, + onAction: retryable ? onRetry : null, + ); + } +} diff --git a/app/lib/features/auth/presentation/login/login_state.dart b/app/lib/features/auth/presentation/login/login_state.dart new file mode 100644 index 0000000..2f42ed1 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_state.dart @@ -0,0 +1,69 @@ +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({}) Set 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._(); + + 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; +} diff --git a/app/lib/features/auth/presentation/login/login_state.freezed.dart b/app/lib/features/auth/presentation/login/login_state.freezed.dart new file mode 100644 index 0000000..dd0f531 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_state.freezed.dart @@ -0,0 +1,340 @@ +// 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 value) => value; +/// @nodoc +mixin _$LoginState { + + String get email; String get password; Set 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 get copyWith => _$LoginStateCopyWithImpl(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); +} + +@override +String toString() { + final _this = this as LoginState; + return 'LoginState(email: ${_this.email}, password: ${_this.password}, visibleFields: ${_this.visibleFields}, status: ${_this.status}, bannerFailure: ${_this.bannerFailure}, unconfirmedEmail: ${_this.unconfirmedEmail}, focusField: ${_this.focusField}, focusRequest: ${_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 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,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 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 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? 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 Function( String email, String password, Set 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 Function( String email, String password, Set 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? Function( String email, String password, Set 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 visibleFields = const {}, 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 _visibleFields; +@override@JsonKey() Set 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); +} + +@override +String toString() { + return 'LoginState(email: $email, password: $password, visibleFields: $visibleFields, status: $status, bannerFailure: $bannerFailure, unconfirmedEmail: $unconfirmedEmail, focusField: $focusField, focusRequest: $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 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,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 diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 1997a07..7efc12a 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -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.", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 0dfc746..5893734 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index b13789c..b23d9f3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 88e00cb..b2b7380 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 14bab3a..b86fff5 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/app/test/features/auth/presentation/auth_router_test.dart b/app/test/features/auth/presentation/auth_router_test.dart index eb46ecc..fce5c1d 100644 --- a/app/test/features/auth/presentation/auth_router_test.dart +++ b/app/test/features/auth/presentation/auth_router_test.dart @@ -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'), diff --git a/app/test/features/auth/presentation/login/login_controller_test.dart b/app/test/features/auth/presentation/login/login_controller_test.dart new file mode 100644 index 0000000..79c21f0 --- /dev/null +++ b/app/test/features/auth/presentation/login/login_controller_test.dart @@ -0,0 +1,225 @@ +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 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, '/'); + expect(state().isSubmitting, isFalse); + 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', + '', + ]) { + 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>(); + when(() => repository.signIn(any(), any())) + .thenAnswer((_) => completer.future); + fill(); + + final first = notifier().submit(); + await Future.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('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); + }); + }); +} diff --git a/app/test/features/auth/presentation/login/login_screen_test.dart b/app/test/features/auth/presentation/login/login_screen_test.dart new file mode 100644 index 0000000..bbe62e8 --- /dev/null +++ b/app/test/features/auth/presentation/login/login_screen_test.dart @@ -0,0 +1,449 @@ +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 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 type(WidgetTester tester, String key, String text) async { + await tester.ensureVisible(fieldOf(key)); + await tester.enterText(fieldOf(key), text); + await tester.pump(); + } + + Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.tap(finder); + await tester.pump(); + } + + Future fill(WidgetTester tester) async { + await type(tester, 'login_email', 'anna@example.com'); + await type(tester, 'login_password', 'geheim-und-lang'); + } + + Future 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(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(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>(); + 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(fieldOf('login_email')).enabled, isFalse); + expect( + tester.widget(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(fieldOf('login_email')).controller!.text, + 'anna@example.com', + ); + expect( + tester.widget(fieldOf('login_password')).controller!.text, + 'geheim-und-lang', + ); + }); + + testWidgets( + 'wrong email and wrong password show exactly the same screen text', + (tester) async { + // The repository maps both cases to invalidCredentials (Spec 4.3); the + // screen must not add anything that tells them apart. + stubSignInFailure(const AuthFailure.invalidCredentials()); + 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); + expect(wrongEmail, contains(_de.authErrorInvalidCredentials)); + verify(() => repository.signIn(any(), any())).called(2); + }, + ); + + testWidgets('english text of the generic failure', (tester) async { + stubSignInFailure(const AuthFailure.invalidCredentials()); + await open(tester, locale: const Locale('en')); + await fill(tester); + + await submit(tester); + + expect(find.text(_en.authErrorInvalidCredentials), findsOneWidget); + expect(find.text(_de.authErrorInvalidCredentials), findsNothing); + }); + + 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(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>(); + 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('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(); + }); + } + + 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( + '${_de.authBannerTypeError}: ${_de.authErrorInvalidCredentials}', + ), + 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)); + }); + }); +} diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index 396cd9d..473b832 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -57,19 +57,35 @@ Der Link ist 1 Stunde gültig. Die E-Mail kann einige Minuten dauern. Schau auc (Modus Reset: -> /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 ) - - + -> /forgot-password + -> /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` 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`) ```