168 lines
5.5 KiB
Dart
168 lines
5.5 KiB
Dart
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<void>? _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<void> loadCommonPasswords() => _commonPasswordsLoad ??= _load();
|
|
|
|
Future<void> _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` or a
|
|
/// server failure such as an SMTP outage, F12/F13).
|
|
Future<bool> 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<bool> 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);
|
|
}
|
|
}
|