feat(online): session compte, stockage sécurisé et adapter API (ticket #64)

Ajoute l'adapter API distant (infrastructure/remote/auth_api.dart,
http_api_client.dart) et le stockage sécurisé du token d'auth
(infrastructure/security/secure_storage_auth_token_store.dart), câblés
dans app_bootstrap.dart. Étend le modèle Drift (migration
schemaVersion 9→10) et la couche application (ports, use cases,
entités) pour la session de compte. flutter pub get OK (ajout http,
flutter_secure_storage), build_runner OK, dart format appliqué,
analyze propre (mêmes infos préexistantes), 109/109 tests verts,
build APK debug validé. Premier ticket du chantier "Ajouter les
features serveur au client" (#63).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:25:49 +02:00
parent a557708273
commit e60c8bbdb8
19 changed files with 1799 additions and 7 deletions

View File

@ -5,6 +5,107 @@ import 'ports.dart';
const Object _useCaseUnchanged = Object();
final class AuthUseCases {
const AuthUseCases({
required this.tokenStore,
required this.accountRepository,
required this.remoteAuthApi,
required this.clock,
required this.ids,
});
final AuthTokenStore tokenStore;
final OnlineAccountRepository accountRepository;
final RemoteAuthApi remoteAuthApi;
final Clock clock;
final IdGenerator ids;
Future<UserAccountSession> register({
required String email,
required String password,
String? displayName,
}) async {
_validateAuthInput(email: email, password: password);
final result = await remoteAuthApi.register(
email: email.trim(),
password: password,
displayName: displayName,
);
return _persistAuthenticatedSession(result, displayName: displayName);
}
Future<UserAccountSession> login({
required String email,
required String password,
}) async {
_validateAuthInput(email: email, password: password);
final result = await remoteAuthApi.login(
email: email.trim(),
password: password,
);
return _persistAuthenticatedSession(result);
}
Future<void> logout() async {
final token = await tokenStore.readToken();
if (token != null) {
try {
await remoteAuthApi.logout(token);
} on RemoteAuthException {
// Local logout must not depend on remote reachability.
}
}
await tokenStore.clearToken();
final session = await accountRepository.currentSession();
if (session != null) {
final now = clock.now();
await accountRepository.saveSession(
session.copyWith(isLoggedIn: false, updatedAt: now),
);
}
}
Future<UserAccountSession?> currentSession() async {
final session = await accountRepository.currentSession();
if (session == null) {
return null;
}
final token = await tokenStore.readToken();
if (token != null || !session.isLoggedIn) {
return session;
}
final updated = session.copyWith(isLoggedIn: false, updatedAt: clock.now());
await accountRepository.saveSession(updated);
return updated;
}
Future<UserAccountSession> _persistAuthenticatedSession(
RemoteAuthResult result, {
String? displayName,
}) async {
await tokenStore.saveToken(result.token, result.expiresAt);
final now = clock.now();
final existing = await accountRepository.currentSession();
final session = UserAccountSession(
id: existing?.id ?? ids.newId(),
serverUserId:
existing != null &&
existing.email.trim().toLowerCase() ==
result.email.trim().toLowerCase()
? existing.serverUserId
: result.userId,
email: result.email,
displayName: displayName ?? existing?.displayName,
isLoggedIn: true,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastAuthenticatedAt: now,
);
await accountRepository.saveSession(session);
return session;
}
}
final class ExerciseUseCases {
const ExerciseUseCases({
required this.repository,
@ -2264,6 +2365,19 @@ void _validateExerciseSteps(List<ExerciseStep> steps) {
}
}
void _validateAuthInput({required String email, required String password}) {
final normalizedEmail = email.trim();
final hasBasicEmailShape = RegExp(
r'^[^@\s]+@[^@\s]+\.[^@\s]+$',
).hasMatch(normalizedEmail);
if (!hasBasicEmailShape) {
throw const DomainException('Email format is invalid.');
}
if (password.length < 8) {
throw const DomainException('Password must contain at least 8 characters.');
}
}
void _validateExerciseDefaultTargets({
required bool hasTimeMeasure,
required bool hasRepsMeasure,