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/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({super.key}); @override ConsumerState createState() => _RegisterScreenState(); } class _RegisterScreenState extends ConsumerState { final _email = TextEditingController(); final _password = TextEditingController(); final _displayName = TextEditingController(); final _emailFocus = FocusNode(); final _passwordFocus = FocusNode(); final _displayNameFocus = FocusNode(); @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); }); _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 _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 _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), onChanged: controller.passwordChanged, ), 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(AppRoutes.login), child: Text(l10n.authRegisterHaveAccount), ), ], ), ), ); } List _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; } 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 is NetworkFailure || failure is Unexpected; return AppBanner( type: AppBannerType.error, typeLabel: l10n.authBannerTypeError, message: authFailureMessage(l10n, failure), actionLabel: retryable && onRetry != null ? l10n.authActionRetry : null, onAction: retryable ? onRetry : null, ); } }