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>
339 lines
12 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|