Fixed-wording tests for the generic credentials error, redacted toString for login/register state, shared AuthFailure.isRetryable, autofill context finished even when the router removes the screen, stricter from validation (encoded and control-character variants), more widget tests. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
246 lines
8.4 KiB
Dart
246 lines
8.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:shuffle/core/l10n/l10n.dart';
|
|
import 'package:shuffle/core/routing/app_routes.dart';
|
|
import 'package:shuffle/core/theme/app_spacing.dart';
|
|
import 'package:shuffle/core/widgets/app_banner.dart';
|
|
import 'package:shuffle/core/widgets/app_form_scaffold.dart';
|
|
import 'package:shuffle/core/widgets/app_password_field.dart';
|
|
import 'package:shuffle/core/widgets/app_primary_button.dart';
|
|
import 'package:shuffle/core/widgets/app_text_field.dart';
|
|
import 'package:shuffle/features/auth/domain/auth_failure.dart';
|
|
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
|
|
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
|
|
import 'package:shuffle/features/auth/presentation/login/login_controller.dart';
|
|
import 'package:shuffle/features/auth/presentation/login/login_state.dart';
|
|
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
|
|
|
|
/// Sign-in (`/login`, KTO-01 Kriterien 5, 6 und 7). Only shows state and
|
|
/// forwards input to the [LoginController]. [from] is the unchecked `from`
|
|
/// query parameter; the controller validates it.
|
|
class LoginScreen extends ConsumerStatefulWidget {
|
|
const LoginScreen({this.from, super.key});
|
|
|
|
final String? from;
|
|
|
|
@override
|
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
final _emailFocus = FocusNode();
|
|
final _passwordFocus = FocusNode();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_emailFocus.addListener(
|
|
() => _onFocusChange(_emailFocus, LoginField.email),
|
|
);
|
|
_passwordFocus.addListener(
|
|
() => _onFocusChange(_passwordFocus, LoginField.password),
|
|
);
|
|
}
|
|
|
|
void _onFocusChange(FocusNode node, LoginField field) {
|
|
if (!node.hasFocus) {
|
|
ref.read(loginControllerProvider.notifier).fieldBlurred(field);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_email.dispose();
|
|
_password.dispose();
|
|
_emailFocus.dispose();
|
|
_passwordFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
final target = await ref
|
|
.read(loginControllerProvider.notifier)
|
|
.submit(from: widget.from);
|
|
if (target == null) {
|
|
return;
|
|
}
|
|
// Also when the router redirect (status signedIn) already removed this
|
|
// screen: the password manager must still learn that sign-in worked.
|
|
TextInput.finishAutofillContext();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
context.go(target);
|
|
}
|
|
|
|
void _requestFocus(LoginField? field) {
|
|
switch (field) {
|
|
case LoginField.email:
|
|
_emailFocus.requestFocus();
|
|
case LoginField.password:
|
|
_passwordFocus.requestFocus();
|
|
case null:
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.listen(loginControllerProvider, (previous, next) {
|
|
if (previous?.focusRequest != next.focusRequest) {
|
|
_requestFocus(next.focusField);
|
|
}
|
|
});
|
|
final state = ref.watch(loginControllerProvider);
|
|
final resend = ref.watch(resendControllerProvider);
|
|
final controller = ref.read(loginControllerProvider.notifier);
|
|
final l10n = context.l10n;
|
|
final busy = state.isSubmitting;
|
|
|
|
return AppFormScaffold(
|
|
key: const Key('login_screen'),
|
|
child: AutofillGroup(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
AuthScreenTitle(l10n.authLoginTitle),
|
|
if (state.bannerFailure case final failure?) ...[
|
|
const SizedBox(height: AppSpacing.lg),
|
|
if (state.showsNotConfirmed)
|
|
AppBanner(
|
|
key: const Key('login_not_confirmed_banner'),
|
|
type: AppBannerType.info,
|
|
typeLabel: l10n.authBannerTypeInfo,
|
|
message: l10n.authErrorEmailNotConfirmed,
|
|
actionLabel: resend.isSending
|
|
? null
|
|
: l10n.authActionResendConfirmation,
|
|
onAction: resend.isSending
|
|
? null
|
|
: () => unawaited(controller.resendConfirmation()),
|
|
)
|
|
else
|
|
_FailureBanner(
|
|
key: const Key('login_banner'),
|
|
failure: failure,
|
|
onRetry: busy ? null : _submit,
|
|
),
|
|
],
|
|
if (state.showsNotConfirmed && resend.sent) ...[
|
|
const SizedBox(height: AppSpacing.md),
|
|
AppBanner(
|
|
key: const Key('login_resent_banner'),
|
|
type: AppBannerType.info,
|
|
typeLabel: l10n.authBannerTypeInfo,
|
|
message: l10n.authCheckEmailResent,
|
|
),
|
|
],
|
|
if ((state.showsNotConfirmed, resend.failure) case (
|
|
true,
|
|
final resendFailure?,
|
|
)) ...[
|
|
const SizedBox(height: AppSpacing.md),
|
|
AppBanner(
|
|
key: const Key('login_resend_error_banner'),
|
|
type: AppBannerType.error,
|
|
typeLabel: l10n.authBannerTypeError,
|
|
message: authFailureMessage(l10n, resendFailure),
|
|
),
|
|
],
|
|
const SizedBox(height: AppSpacing.xl),
|
|
AppTextField(
|
|
key: const Key('login_email'),
|
|
label: l10n.authFieldEmailLabel,
|
|
controller: _email,
|
|
focusNode: _emailFocus,
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
autofillHints: const [
|
|
AutofillHints.username,
|
|
AutofillHints.email,
|
|
],
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
enabled: !busy,
|
|
errorText: state.shownEmailError
|
|
? l10n.authErrorEmailInvalid
|
|
: null,
|
|
onChanged: controller.emailChanged,
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
AppPasswordField(
|
|
key: const Key('login_password'),
|
|
label: l10n.authFieldPasswordLabel,
|
|
showPasswordLabel: l10n.authPasswordShow,
|
|
hidePasswordLabel: l10n.authPasswordHide,
|
|
controller: _password,
|
|
focusNode: _passwordFocus,
|
|
textInputAction: TextInputAction.done,
|
|
enabled: !busy,
|
|
errorText: state.shownPasswordRequired
|
|
? l10n.authPasswordRequired
|
|
: null,
|
|
onChanged: controller.passwordChanged,
|
|
onSubmitted: (_) {
|
|
if (!busy) {
|
|
unawaited(_submit());
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: AppSpacing.xl),
|
|
AppPrimaryButton(
|
|
key: const Key('login_submit'),
|
|
label: l10n.authLoginSubmit,
|
|
loadingLabel: l10n.authSubmitting,
|
|
isLoading: busy,
|
|
onPressed: _submit,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
TextButton(
|
|
key: const Key('login_forgot_link'),
|
|
onPressed: busy
|
|
? null
|
|
: () => context.go(AppRoutes.forgotPassword),
|
|
child: Text(l10n.authLoginForgotPassword),
|
|
),
|
|
TextButton(
|
|
key: const Key('login_register_link'),
|
|
onPressed: busy ? null : () => context.go(AppRoutes.register),
|
|
child: Text(l10n.authLoginCreateAccount),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FailureBanner extends StatelessWidget {
|
|
const _FailureBanner({
|
|
required this.failure,
|
|
required this.onRetry,
|
|
super.key,
|
|
});
|
|
|
|
final AuthFailure failure;
|
|
final VoidCallback? onRetry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final retryable = failure.isRetryable;
|
|
return AppBanner(
|
|
type: AppBannerType.error,
|
|
typeLabel: l10n.authBannerTypeError,
|
|
message: authFailureMessage(l10n, failure),
|
|
actionLabel: retryable && onRetry != null ? l10n.authActionRetry : null,
|
|
onAction: retryable ? onRetry : null,
|
|
);
|
|
}
|
|
}
|