import 'dart:async'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shuffle/core/config/app_config_provider.dart'; import 'package:shuffle/core/logging/app_logger.dart'; import 'package:shuffle/core/platform/url_opener.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:shuffle/features/auth/domain/resend_origin.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/providers/auth_providers.dart'; import 'package:shuffle/features/auth/presentation/register/register_state.dart'; part 'register_controller.g.dart'; /// Legal texts linked from the consent checkbox. enum LegalDocument { privacy, terms } final _log = AppLogger('RegisterController'); /// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). @riverpod class RegisterController extends _$RegisterController { Future? _commonPasswordsLoad; @override RegisterState build() => const RegisterState(); void emailChanged(String value) { state = state.copyWith(email: value, serverEmailInvalid: false); } void passwordChanged(String value) { // The password is checked while typing (Kriterium 3). state = state.copyWith( password: value, visibleFields: {...state.visibleFields, RegisterField.password}, serverPasswordReasons: null, ); unawaited(loadCommonPasswords()); } void displayNameChanged(String value) { state = state.copyWith(displayName: value); } void ageConfirmedChanged({required bool value}) { state = state.copyWith(ageConfirmed: value); } void termsAcceptedChanged({required bool value}) { state = state.copyWith(termsAccepted: value); } /// Field left: show its error (text fields only). void fieldBlurred(RegisterField field) { if (field == RegisterField.ageConfirmation || field == RegisterField.termsAcceptance) { return; } state = state.copyWith(visibleFields: {...state.visibleFields, field}); } /// Starts loading the list of common passwords (lazy, once). Called when /// the password field is used. Future loadCommonPasswords() => _commonPasswordsLoad ??= _load(); Future _load() async { try { final list = await ref.read(commonPasswordListProvider.future); if (!ref.mounted) { return; } state = state.copyWith(commonPasswords: list); } on Object catch (error) { // Fallback: only the length rule runs in the client; the server // enforces the minimum length as well. _log.warning('common password list unavailable: ${error.runtimeType}'); } } /// Validates and submits. Returns `true` if the screen should continue to /// "check your inbox" (every 200 response, and `emailSendLimited`, F12). Future submit() async { if (state.isSubmitting) { return false; } // Set before the first await, otherwise a second trigger (button and // Enter key) passes the guard while the list is still loading. state = state.copyWith(status: RegisterStatus.submitting); await loadCommonPasswords(); if (!ref.mounted) { return false; } state = state.copyWith( visibleFields: RegisterField.values.toSet(), bannerFailure: null, serverPasswordReasons: null, serverEmailInvalid: false, focusField: null, ); final invalid = state.firstInvalidField; if (invalid != null) { state = state.copyWith( status: RegisterStatus.editing, focusField: invalid, focusRequest: state.focusRequest + 1, ); return false; } final input = ref.read(registrationInputFactoryProvider)( email: EmailFormat.normalize(state.email), password: state.password, displayName: DisplayNameRule.normalize(state.displayName), ageConfirmed: state.ageConfirmed, termsAccepted: state.termsAccepted, ); final result = await ref.read(authRepositoryProvider).signUp(input); if (!ref.mounted) { return false; } final failure = result.errorOrNull; state = state.copyWith(status: RegisterStatus.editing); if (failure == null || openEmailInputFailureFor(failure) == null) { return true; } _showFailure(failure); return false; } void _showFailure(AuthFailure failure) { switch (failure) { case WeakPassword(:final reasons): state = state.copyWith( serverPasswordReasons: reasons, focusField: RegisterField.password, focusRequest: state.focusRequest + 1, ); case InvalidEmail(): state = state.copyWith( serverEmailInvalid: true, focusField: RegisterField.email, focusRequest: state.focusRequest + 1, ); default: state = state.copyWith(bannerFailure: failure); } } /// Opens a legal text in the browser. `false` if that did not work. Future openLegalDocument(LegalDocument document) async { final config = ref.read(appConfigProvider); final url = switch (document) { LegalDocument.privacy => config.legalPrivacyUrl, LegalDocument.terms => config.legalTermsUrl, }; final uri = Uri.tryParse(url); if (uri == null || !isOpenableExternalUrl(uri)) { _log.warning('invalid legal url for ${document.name}'); return false; } return ref.read(urlOpenerProvider)(uri); } }