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>
252 lines
8.7 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|