Shuffle/app/lib/features/auth/presentation/login/login_controller.dart
cschlaefke 57305dad4b fix(auth): address review findings for login
Fixed-wording tests for the generic credentials error, redacted toString for
login/register state, shared AuthFailure.isRetryable, autofill context
finished even when the router removes the screen, stricter from validation
(encoded and control-character variants), more widget tests.

Refs #1

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

93 lines
3.2 KiB
Dart

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/routing/app_routes.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/email_format.dart';
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
import 'package:shuffle/features/auth/presentation/login/login_state.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
part 'login_controller.g.dart';
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
@riverpod
class LoginController extends _$LoginController {
@override
LoginState build() => const LoginState();
void emailChanged(String value) {
state = state.copyWith(email: value);
}
void passwordChanged(String value) {
state = state.copyWith(password: value);
}
/// Field left: show its error.
void fieldBlurred(LoginField field) {
state = state.copyWith(visibleFields: {...state.visibleFields, field});
}
/// Validates and signs in. Returns the path to continue to on success
/// (the validated `from` target, otherwise the start page), `null` if the
/// user stays on the screen. A second call while one runs does nothing.
Future<String?> submit({String? from}) async {
if (state.isSubmitting) {
return null;
}
state = state.copyWith(
status: LoginStatus.submitting,
visibleFields: LoginField.values.toSet(),
bannerFailure: null,
unconfirmedEmail: null,
focusField: null,
);
// A new attempt starts without the result of an earlier resend.
ref.invalidate(resendControllerProvider);
final invalid = state.firstInvalidField;
if (invalid != null) {
state = state.copyWith(
status: LoginStatus.editing,
focusField: invalid,
focusRequest: state.focusRequest + 1,
);
return null;
}
final email = EmailFormat.normalize(state.email);
final result = await ref
.read(authRepositoryProvider)
.signIn(email, state.password);
final failure = result.errorOrNull;
if (failure == null) {
// Success: the target is returned even if the screen is already gone
// (the router redirect can remove it before this future completes), so
// the caller can still finish the autofill context. The status stays
// `submitting`, the form is not reused after a successful sign-in.
return safeFromPath(from) ?? AppRoutes.home;
}
if (!ref.mounted) {
return null;
}
state = state.copyWith(
status: LoginStatus.editing,
bannerFailure: failure,
unconfirmedEmail: failure is EmailNotConfirmed ? email : null,
);
return null;
}
/// "Resend confirmation email" after `emailNotConfirmed` (Kriterium 6).
/// The password was right, so a mail-limit answer is shown (F12).
Future<void> resendConfirmation() async {
final email = state.unconfirmedEmail;
if (email == null) {
return;
}
await ref
.read(resendControllerProvider.notifier)
.resend(email, origin: ResendOrigin.login);
}
}