Shuffle/app/lib/features/auth/presentation/login/login_screen.dart
cschlaefke 87c0d7d1ec feat(auth): add password reset (forgot password, recovery link, new password)
ForgotPasswordScreen with neutral reaction (F12), CheckEmail reset mode on
its own path, recovery link handling and ResetPasswordScreen with the
recovery guard held until the user confirms the success message. Carries
the from parameter between login, register and forgot password. Removes
the route placeholders.

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:47:03 +01:00

252 lines
8.7 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/providers/auth_redirect.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: l10n.authActionResendConfirmation,
onAction: () => unawaited(controller.resendConfirmation()),
// Stays visible while sending, only disabled (no double
// send, no vanishing control under screen reader focus).
actionEnabled: !resend.isSending,
)
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(
authLocation(AppRoutes.forgotPassword, from: widget.from),
),
child: Text(l10n.authLoginForgotPassword),
),
TextButton(
key: const Key('login_register_link'),
onPressed: busy
? null
: () => context.go(
authLocation(AppRoutes.register, from: widget.from),
),
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,
);
}
}