Shuffle/app/lib/features/auth/presentation/register/register_state.dart
cschlaefke e632da2344 feat(auth): add registration, check-email and email-link screens
Implements KTO-01 criteria 1-4, 10 and 11 (client part).

Refs #1

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

115 lines
3.8 KiB
Dart

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.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';
part 'register_state.freezed.dart';
/// Form fields of the registration, in reading order.
enum RegisterField {
email,
password,
displayName,
ageConfirmation,
termsAcceptance,
}
enum RegisterStatus { editing, submitting }
/// Values, visibility of errors and submit state of the registration form.
///
/// Errors are derived from the values by the domain rules. Which of them are
/// shown is decided by [visibleFields] (blur, typing in the password field,
/// submit attempt).
@freezed
abstract class RegisterState with _$RegisterState {
const factory RegisterState({
@Default('') String email,
@Default('') String password,
@Default('') String displayName,
@Default(false) bool ageConfirmed,
@Default(false) bool termsAccepted,
@Default(<RegisterField>{}) Set<RegisterField> visibleFields,
@Default(RegisterStatus.editing) RegisterStatus status,
/// Loaded lazily with the first use of the password field.
CommonPasswordList? commonPasswords,
/// General failure shown as banner.
AuthFailure? bannerFailure,
/// `weak_password` reasons reported by the server, shown at the password.
List<WeakPasswordReason>? serverPasswordReasons,
/// The server rejected the address syntax; shown at the email field.
@Default(false) bool serverEmailInvalid,
/// Field that should receive focus after a failed submit.
RegisterField? focusField,
/// Incremented whenever [focusField] is set, so the screen can react
/// to repeated requests for the same field.
@Default(0) int focusRequest,
}) = _RegisterState;
const RegisterState._();
bool get isSubmitting => status == RegisterStatus.submitting;
EmailError? get emailError => EmailFormat.validate(email);
PasswordError? get passwordError =>
PasswordPolicy.validate(password, common: commonPasswords);
DisplayNameError? get displayNameError =>
DisplayNameRule.validate(displayName);
int get displayNameLength => DisplayNameRule.length(displayName);
bool get ageError => !ageConfirmed;
bool get termsError => !termsAccepted;
bool isVisible(RegisterField field) => visibleFields.contains(field);
/// Local error of [field] (ignoring visibility).
bool hasError(RegisterField field) => switch (field) {
RegisterField.email => emailError != null,
RegisterField.password => password.isEmpty || passwordError != null,
RegisterField.displayName => displayNameError != null,
RegisterField.ageConfirmation => ageError,
RegisterField.termsAcceptance => termsError,
};
RegisterField? get firstInvalidField {
for (final field in RegisterField.values) {
if (hasError(field)) {
return field;
}
}
return null;
}
// Errors as the screen shows them.
EmailError? get shownEmailError =>
isVisible(RegisterField.email) ? emailError : null;
/// Empty password is "required", otherwise the policy error.
bool get shownPasswordRequired =>
isVisible(RegisterField.password) && password.isEmpty;
PasswordError? get shownPasswordError =>
isVisible(RegisterField.password) ? passwordError : null;
DisplayNameError? get shownDisplayNameError =>
isVisible(RegisterField.displayName) ? displayNameError : null;
bool get shownAgeError =>
isVisible(RegisterField.ageConfirmation) && ageError;
bool get shownTermsError =>
isVisible(RegisterField.termsAcceptance) && termsError;
}