Shuffle/app/lib/features/auth/presentation/login/login_state.dart
cschlaefke dd91c078e0 feat(auth): add login screen with unconfirmed-account resend and safe redirect
KTO-01 Kriterien 5, 6 und 7 (01b): LoginController/State/Screen replace the
/login placeholder. Generic failure text for wrong email and password,
resend via ResendController (ResendOrigin.login), throttle and offline
banners, autofill hints, double-submit guard, validated from redirect.

Refs #1

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

69 lines
2.1 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/email_format.dart';
part 'login_state.freezed.dart';
/// Form fields of the sign-in, in reading order.
enum LoginField { email, password }
enum LoginStatus { editing, submitting }
/// Values, visibility of field errors and submit state of the sign-in form.
///
/// Credential failures are never attached to a field (Kriterium 7): they
/// show as [bannerFailure] above the form.
@freezed
abstract class LoginState with _$LoginState {
const factory LoginState({
@Default('') String email,
@Default('') String password,
@Default(<LoginField>{}) Set<LoginField> visibleFields,
@Default(LoginStatus.editing) LoginStatus status,
/// Failure of the last attempt, shown as banner.
AuthFailure? bannerFailure,
/// Address of the last attempt that ended in `emailNotConfirmed`. The
/// resend action uses this one, not whatever is in the field now.
String? unconfirmedEmail,
/// Field that should receive focus after a failed submit.
LoginField? focusField,
/// Incremented whenever [focusField] is set.
@Default(0) int focusRequest,
}) = _LoginState;
const LoginState._();
bool get isSubmitting => status == LoginStatus.submitting;
EmailError? get emailError => EmailFormat.validate(email);
bool get passwordMissing => password.isEmpty;
bool hasError(LoginField field) => switch (field) {
LoginField.email => emailError != null,
LoginField.password => passwordMissing,
};
LoginField? get firstInvalidField {
for (final field in LoginField.values) {
if (hasError(field)) {
return field;
}
}
return null;
}
bool isVisible(LoginField field) => visibleFields.contains(field);
bool get shownEmailError => isVisible(LoginField.email) && emailError != null;
bool get shownPasswordRequired =>
isVisible(LoginField.password) && passwordMissing;
bool get showsNotConfirmed =>
bannerFailure is EmailNotConfirmed && unconfirmedEmail != null;
}