feat(auth): add login screen with unconfirmed-account resend and safe redirect
KTO-01 Kriterien 5, 6 und 7 (01b): LoginController/State/Screen replace the /login placeholder. Generic failure text for wrong email and password, resend via ResendController (ResendOrigin.login), throttle and offline banners, autofill hints, double-submit guard, validated from redirect. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa7238a2ef
commit
dd91c078e0
15 changed files with 1569 additions and 13 deletions
|
|
@ -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/auth_route_placeholder.dart';
|
||||||
import 'package:shuffle/features/auth/presentation/check_email/check_email_screen.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/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';
|
import 'package:shuffle/features/auth/presentation/register/register_screen.dart';
|
||||||
|
|
||||||
/// Routes of the auth pages (Spec #1 § 5.3). Login, forgot and reset password
|
/// Routes of the auth pages (Spec #1 § 5.3). Forgot and reset password are
|
||||||
/// are placeholders until 01b and 01c.
|
/// placeholders until 01c.
|
||||||
final List<RouteBase> authRoutes = [
|
final List<RouteBase> authRoutes = [
|
||||||
authPlaceholderRoute(AppRoutes.login, 'login'),
|
GoRoute(
|
||||||
|
path: AppRoutes.login,
|
||||||
|
builder: (context, state) =>
|
||||||
|
LoginScreen(from: state.uri.queryParameters[AppRoutes.fromParam]),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.register,
|
path: AppRoutes.register,
|
||||||
builder: (context, state) => const RegisterScreen(),
|
builder: (context, state) => const RegisterScreen(),
|
||||||
|
|
|
||||||
|
|
@ -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<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);
|
||||||
|
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<void> resendConfirmation() async {
|
||||||
|
final email = state.unconfirmedEmail;
|
||||||
|
if (email == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(resendControllerProvider.notifier)
|
||||||
|
.resend(email, origin: ResendOrigin.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'f8c37f2b7b942c8cfffbf8b61f3296872b5bcb70';
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
app/lib/features/auth/presentation/login/login_screen.dart
Normal file
238
app/lib/features/auth/presentation/login/login_screen.dart
Normal file
|
|
@ -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<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 (!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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/lib/features/auth/presentation/login/login_state.dart
Normal file
69
app/lib/features/auth/presentation/login/login_state.dart
Normal file
|
|
@ -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(<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._();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<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
|
||||||
|
|
@ -47,6 +47,10 @@
|
||||||
"authRegisterTitle": "Konto erstellen",
|
"authRegisterTitle": "Konto erstellen",
|
||||||
"authRegisterSubmit": "Konto erstellen",
|
"authRegisterSubmit": "Konto erstellen",
|
||||||
"authRegisterHaveAccount": "Schon ein Konto? Anmelden",
|
"authRegisterHaveAccount": "Schon ein Konto? Anmelden",
|
||||||
|
"authLoginTitle": "Anmelden",
|
||||||
|
"authLoginSubmit": "Anmelden",
|
||||||
|
"authLoginForgotPassword": "Passwort vergessen?",
|
||||||
|
"authLoginCreateAccount": "Konto erstellen",
|
||||||
"authCheckEmailTitle": "Prüfe dein Postfach",
|
"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.",
|
"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.",
|
"authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.",
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,10 @@
|
||||||
"authRegisterTitle": "Create account",
|
"authRegisterTitle": "Create account",
|
||||||
"authRegisterSubmit": "Create account",
|
"authRegisterSubmit": "Create account",
|
||||||
"authRegisterHaveAccount": "Already have an account? Sign in",
|
"authRegisterHaveAccount": "Already have an account? Sign in",
|
||||||
|
"authLoginTitle": "Sign in",
|
||||||
|
"authLoginSubmit": "Sign in",
|
||||||
|
"authLoginForgotPassword": "Forgot password?",
|
||||||
|
"authLoginCreateAccount": "Create account",
|
||||||
"authCheckEmailTitle": "Check your inbox",
|
"authCheckEmailTitle": "Check your inbox",
|
||||||
"authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.",
|
"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.",
|
"authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.",
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,30 @@ abstract class AppLocalizations {
|
||||||
/// **'Already have an account? Sign in'**
|
/// **'Already have an account? Sign in'**
|
||||||
String get authRegisterHaveAccount;
|
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.
|
/// No description provided for @authCheckEmailTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get authRegisterHaveAccount => 'Schon ein Konto? Anmelden';
|
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
|
@override
|
||||||
String get authCheckEmailTitle => 'Prüfe dein Postfach';
|
String get authCheckEmailTitle => 'Prüfe dein Postfach';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get authRegisterHaveAccount => 'Already have an account? Sign in';
|
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
|
@override
|
||||||
String get authCheckEmailTitle => 'Check your inbox';
|
String get authCheckEmailTitle => 'Check your inbox';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ void main() {
|
||||||
|
|
||||||
expect(currentPath(router), '/login');
|
expect(currentPath(router), '/login');
|
||||||
expect(currentUri(router).queryParameters['from'], '/private?tab=1');
|
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 {
|
testWidgets('signing in on /login returns to from', (tester) async {
|
||||||
|
|
@ -116,12 +116,12 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
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 {
|
(tester) async {
|
||||||
final (container: _, :router) = await start(tester);
|
final (container: _, :router) = await start(tester);
|
||||||
|
|
||||||
for (final (path, key) in [
|
for (final (path, key) in [
|
||||||
('/login', 'auth_placeholder_login'),
|
('/login', 'login_screen'),
|
||||||
('/register', 'register_screen'),
|
('/register', 'register_screen'),
|
||||||
('/register/check-email', 'check_email_screen'),
|
('/register/check-email', 'check_email_screen'),
|
||||||
('/forgot-password', 'auth_placeholder_forgot_password'),
|
('/forgot-password', 'auth_placeholder_forgot_password'),
|
||||||
|
|
|
||||||
|
|
@ -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<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, '/');
|
||||||
|
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<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('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
449
app/test/features/auth/presentation/login/login_screen_test.dart
Normal file
449
app/test/features/auth/presentation/login/login_screen_test.dart
Normal file
|
|
@ -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<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 {
|
||||||
|
// 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<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('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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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)
|
<Zurück zur Anmeldung> (Modus Reset: <Andere E-Mail-Adresse eingeben> -> /forgot-password)
|
||||||
```
|
```
|
||||||
|
|
||||||
### LoginScreen (`/login`)
|
### LoginScreen (`/login`, umgesetzt in 01b)
|
||||||
|
|
||||||
```
|
```
|
||||||
Anmelden
|
Anmelden
|
||||||
! Fehler-Banner: authInvalidCredentials | rateLimited | network (über dem Formular, nie am Feld)
|
! Fehler-Banner: authErrorInvalidCredentials | authErrorRateLimited | authErrorNetwork (+ "Erneut versuchen")
|
||||||
! Info-Banner emailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden"
|
! Info-Banner authErrorEmailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden"
|
||||||
E-Mail [ ]
|
! Info-Banner "Wir haben die E-Mail erneut gesendet ..." | Fehler-Banner (authErrorRateLimited / authErrorNetwork) nach dem Resend
|
||||||
Passwort [ ••••• (Auge) ]
|
E-Mail [ ] Fehler (nur lokal): authErrorEmailInvalid
|
||||||
|
Passwort [ ••••• (Auge) ] Fehler (nur lokal): authPasswordRequired
|
||||||
( Anmelden )
|
( Anmelden )
|
||||||
<Passwort vergessen?>
|
<Passwort vergessen?> -> /forgot-password
|
||||||
<Konto erstellen>
|
<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`)
|
### ForgotPasswordScreen (`/forgot-password`)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue