Shuffle/app/lib/features/auth/presentation/login/login_screen.dart
cschlaefke 57305dad4b fix(auth): address review findings for login
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>
2026-11-03 09:07:56 +01:00

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,
);
}
}