Shuffle/app/lib/features/auth/presentation/register/register_screen.dart
cschlaefke 239d0e1ac2
All checks were successful
CI / flutter (pull_request) Successful in 1m16s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 32s
fix(auth): address review findings for registration
- expose legal links to screen readers on web
- allow only http(s) legal URLs, prevent double submit
- detect transport errors without runtimeType, keep extra in memory
- ship third-party license text, document decisions

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:47:42 +01:00

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/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<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(AppRoutes.login),
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 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,
);
}
}