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:
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user