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

@ -1,7 +1,10 @@
import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart';
import 'application.dart';
abstract interface class AppDependencies {
AuthUseCases get authUseCases;
ExerciseUseCases get exerciseUseCases;
MediaUseCases get mediaUseCases;
ProgramUseCases get programUseCases;
@ -15,6 +18,7 @@ abstract interface class AppDependencies {
final class AppBootstrap implements AppDependencies {
AppBootstrap._({
required this.database,
required this.authUseCases,
required this.exerciseUseCases,
required this.mediaUseCases,
required this.programUseCases,
@ -28,6 +32,8 @@ final class AppBootstrap implements AppDependencies {
final AppDatabase database;
@override
final AuthUseCases authUseCases;
@override
final ExerciseUseCases exerciseUseCases;
@override
final MediaUseCases mediaUseCases;
@ -50,6 +56,7 @@ final class AppBootstrap implements AppDependencies {
await database.customSelect('SELECT 1').get();
final exerciseRepository = DriftExerciseRepository(database);
final mediaRepository = DriftMediaAssetRepository(database);
final onlineAccountRepository = DriftOnlineAccountRepository(database);
final programRepository = DriftProgramRepository(database);
final templateRepository = DriftWorkoutTemplateRepository(database);
final activeSessionRepository = DriftActiveSessionRepository(database);
@ -57,9 +64,19 @@ final class AppBootstrap implements AppDependencies {
final ids = LocalIdGenerator();
const clock = SystemClock();
const originDeviceId = 'local-device';
final remoteAuthApi = HttpRemoteAuthApi(
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
);
return AppBootstrap._(
database: database,
authUseCases: AuthUseCases(
tokenStore: const SecureStorageAuthTokenStore(),
accountRepository: onlineAccountRepository,
remoteAuthApi: remoteAuthApi,
clock: clock,
ids: ids,
),
exerciseUseCases: ExerciseUseCases(
repository: exerciseRepository,
programRepository: programRepository,

View File

@ -15,6 +15,64 @@ abstract interface class IdGenerator {
String newId();
}
enum RemoteAuthFailure {
invalidCredentials,
emailAlreadyUsed,
network,
unknown,
}
final class RemoteAuthException implements Exception {
const RemoteAuthException(this.failure, [this.message]);
final RemoteAuthFailure failure;
final String? message;
@override
String toString() => message ?? failure.name;
}
final class RemoteAuthResult {
const RemoteAuthResult({
required this.userId,
required this.email,
required this.token,
required this.expiresAt,
});
final String userId;
final String email;
final String token;
final DateTime expiresAt;
}
abstract interface class AuthTokenStore {
Future<void> saveToken(String token, DateTime expiresAt);
Future<String?> readToken();
Future<void> clearToken();
}
abstract interface class OnlineAccountRepository {
Future<UserAccountSession?> currentSession();
Future<void> saveSession(UserAccountSession session);
Future<void> clearSession();
}
abstract interface class RemoteAuthApi {
Future<RemoteAuthResult> register({
required String email,
required String password,
String? displayName,
});
Future<RemoteAuthResult> login({
required String email,
required String password,
});
Future<void> logout(String token);
}
abstract interface class ExerciseRepository {
Future<Exercise?> findById(String id);
Future<List<Exercise>> listActive();

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,