Shuffle/app/lib/features/auth/presentation/register/register_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

339 lines
12 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_checkbox_field.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/domain/validation/display_name_rule.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:shuffle/features/auth/presentation/register/register_controller.dart';
import 'package:shuffle/features/auth/presentation/register/register_state.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// Registration (`/register`, KTO-01 Kriterien 1 bis 4 und 10, QUE-04
/// Kriterium 1). Only shows state and forwards input to the
/// [RegisterController].
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({this.from, super.key});
/// Unchecked `from` query parameter, carried back to the sign-in page.
final String? from;
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
final _displayName = TextEditingController();
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
final _displayNameFocus = FocusNode();
/// The user is typing in the password field (see `liveError` below).
bool _passwordTyping = false;
@override
void initState() {
super.initState();
_emailFocus.addListener(
() => _onFocusChange(_emailFocus, RegisterField.email),
);
_passwordFocus.addListener(() {
if (_passwordFocus.hasFocus) {
// The list of common passwords loads lazily on first use.
unawaited(
ref.read(registerControllerProvider.notifier).loadCommonPasswords(),
);
}
_onFocusChange(_passwordFocus, RegisterField.password);
if (!_passwordFocus.hasFocus && _passwordTyping && mounted) {
// Field left: the length hint may be announced now.
setState(() => _passwordTyping = false);
}
});
_displayNameFocus.addListener(
() => _onFocusChange(_displayNameFocus, RegisterField.displayName),
);
}
void _onFocusChange(FocusNode node, RegisterField field) {
if (!node.hasFocus) {
ref.read(registerControllerProvider.notifier).fieldBlurred(field);
}
}
@override
void dispose() {
_email.dispose();
_password.dispose();
_displayName.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
_displayNameFocus.dispose();
super.dispose();
}
Future<void> _submit() async {
final controller = ref.read(registerControllerProvider.notifier);
final succeeded = await controller.submit();
if (!mounted || !succeeded) {
return;
}
final email = EmailFormat.normalize(
ref.read(registerControllerProvider).email,
);
TextInput.finishAutofillContext();
context.go(AppRoutes.registerCheckEmail, extra: email);
}
Future<void> _openLegal(LegalDocument document) async {
final opened = await ref
.read(registerControllerProvider.notifier)
.openLegalDocument(document);
if (!mounted || opened) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.authErrorUnexpected)));
}
void _requestFocus(RegisterField? field) {
final node = switch (field) {
RegisterField.email => _emailFocus,
RegisterField.password => _passwordFocus,
RegisterField.displayName => _displayNameFocus,
_ => null,
};
node?.requestFocus();
}
@override
Widget build(BuildContext context) {
ref.listen(registerControllerProvider, (previous, next) {
if (previous?.focusRequest != next.focusRequest) {
_requestFocus(next.focusField);
}
});
final state = ref.watch(registerControllerProvider);
final controller = ref.read(registerControllerProvider.notifier);
final l10n = context.l10n;
final busy = state.isSubmitting;
return AppFormScaffold(
key: const Key('register_screen'),
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthScreenTitle(l10n.authRegisterTitle),
if (state.bannerFailure case final failure?) ...[
const SizedBox(height: AppSpacing.lg),
_FailureBanner(
key: const Key('register_banner'),
failure: failure,
onRetry: busy ? null : _submit,
),
],
const SizedBox(height: AppSpacing.xl),
AppTextField(
key: const Key('register_email'),
label: l10n.authFieldEmailLabel,
controller: _email,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
autocorrect: false,
enableSuggestions: false,
enabled: !busy,
errorText: _emailError(l10n, state),
onChanged: controller.emailChanged,
),
const SizedBox(height: AppSpacing.lg),
AppPasswordField(
key: const Key('register_password'),
label: l10n.authFieldPasswordLabel,
showPasswordLabel: l10n.authPasswordShow,
hidePasswordLabel: l10n.authPasswordHide,
controller: _password,
focusNode: _passwordFocus,
isNewPassword: true,
textInputAction: TextInputAction.next,
enabled: !busy,
ruleHint: l10n.authPasswordRule,
errorText: _passwordError(l10n, state),
// "n missing" changes with every keystroke: announce it only
// when the field is left. The visible hint stays.
liveError: !_passwordTyping || !_isLengthHintOnly(state),
onChanged: (value) {
if (!_passwordTyping) {
setState(() => _passwordTyping = true);
}
controller.passwordChanged(value);
},
),
const SizedBox(height: AppSpacing.lg),
AppTextField(
key: const Key('register_display_name'),
label: l10n.authFieldDisplayNameLabel,
controller: _displayName,
focusNode: _displayNameFocus,
textInputAction: TextInputAction.done,
textCapitalization: TextCapitalization.words,
autofillHints: const [AutofillHints.nickname],
maxLength: DisplayNameRule.maxLength,
showCounter: true,
counterText: l10n.authDisplayNameCounter(
state.displayNameLength,
DisplayNameRule.maxLength,
),
enabled: !busy,
errorText: _displayNameError(l10n, state),
onChanged: controller.displayNameChanged,
onSubmitted: (_) {
if (!busy) {
unawaited(_submit());
}
},
),
const SizedBox(height: AppSpacing.lg),
AppCheckboxField(
key: const Key('register_age_checkbox'),
value: state.ageConfirmed,
label: l10n.authCheckboxAgeLabel,
enabled: !busy,
errorText: state.shownAgeError ? l10n.authAgeNotConfirmed : null,
onChanged: (value) =>
controller.ageConfirmedChanged(value: value),
),
AppCheckboxField.rich(
key: const Key('register_terms_checkbox'),
value: state.termsAccepted,
enabled: !busy,
segments: _termsSegments(l10n),
errorText: state.shownTermsError
? l10n.authTermsNotAccepted
: null,
onChanged: (value) =>
controller.termsAcceptedChanged(value: value),
),
const SizedBox(height: AppSpacing.xl),
AppPrimaryButton(
key: const Key('register_submit'),
label: l10n.authRegisterSubmit,
loadingLabel: l10n.authSubmitting,
isLoading: busy,
onPressed: _submit,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
key: const Key('register_login_link'),
onPressed: busy
? null
: () => context.go(
authLocation(AppRoutes.login, from: widget.from),
),
child: Text(l10n.authRegisterHaveAccount),
),
],
),
),
);
}
List<AppLabelSegment> _termsSegments(AppLocalizations l10n) {
final suffix = l10n.authCheckboxTermsSuffix;
return [
AppLabelSegment.text('${l10n.authCheckboxTermsPrefix} '),
AppLabelSegment.link(
l10n.authCheckboxTermsPrivacyLink,
() => _openLegal(LegalDocument.privacy),
),
AppLabelSegment.text(' ${l10n.authCheckboxTermsMiddle} '),
AppLabelSegment.link(
l10n.authCheckboxTermsTermsLink,
() => _openLegal(LegalDocument.terms),
),
if (suffix.isNotEmpty) AppLabelSegment.text(' $suffix'),
];
}
String? _emailError(AppLocalizations l10n, RegisterState state) {
if (state.serverEmailInvalid || state.shownEmailError != null) {
return l10n.authErrorEmailInvalid;
}
return null;
}
/// The only password message shown is the "n missing" count (no server
/// answer, not "required").
bool _isLengthHintOnly(RegisterState state) =>
state.serverPasswordReasons == null &&
!state.shownPasswordRequired &&
state.shownPasswordError is PasswordTooShort;
String? _passwordError(AppLocalizations l10n, RegisterState state) {
if (state.serverPasswordReasons case final reasons?) {
return weakPasswordMessage(l10n, reasons);
}
if (state.shownPasswordRequired) {
return l10n.authPasswordRequired;
}
return switch (state.shownPasswordError) {
PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing),
PasswordCommon() => l10n.authPasswordCommon,
null => null,
};
}
String? _displayNameError(AppLocalizations l10n, RegisterState state) =>
switch (state.shownDisplayNameError) {
DisplayNameError.empty => l10n.authDisplayNameEmpty,
DisplayNameError.tooLong => l10n.authDisplayNameTooLong(
DisplayNameRule.maxLength,
),
// U+200B: same message as "invalid" (Spec #1 § 5.4).
DisplayNameError.invalidCharacter => l10n.authDisplayNameInvalid,
null => null,
};
}
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,
);
}
}