Finish the recovery guard only after the password change, mark the change in the repository so a late userUpdated event is harmless, keep the form locked after success, carry from through the reset check-email page, share the password error texts and focus the continue button. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
331 lines
12 KiB
Dart
331 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/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;
|
|
}
|
|
|
|
bool _isLengthHintOnly(RegisterState state) => isPasswordLengthHintOnly(
|
|
error: state.shownPasswordError,
|
|
required: state.shownPasswordRequired,
|
|
serverReasons: state.serverPasswordReasons,
|
|
);
|
|
|
|
String? _passwordError(AppLocalizations l10n, RegisterState state) =>
|
|
passwordFieldError(
|
|
l10n,
|
|
error: state.shownPasswordError,
|
|
required: state.shownPasswordRequired,
|
|
serverReasons: state.serverPasswordReasons,
|
|
);
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|