feat(online): sync client incrémentale LWW et profil/écrans auth (tickets #65, #67)

Ajoute la synchronisation client incrémentale LWW vers l'API serveur
(application/use_cases.dart: SyncUseCases, ports.dart, migration
Drift schemaVersion 10→11 pour les métadonnées de sync et mappings de
ressources, infrastructure/remote/sync_api.dart) et l'écran de profil
avec entrée sur l'accueil (presentation/profile_screen.dart,
home_screen.dart, presentation.dart). Développés dans le même
worktree partagé par DevBackend et DevFrontend ; commit combiné car
test/presentation/home_screen_test.dart mélange authentiquement les
deux tickets (le test de l'entrée Profil et la mise à jour du fake de
bootstrap requise par le nouveau getter syncUseCases sur
AppDependencies). Corrige au passage une couleur `crimson` inexistante
dans le thème (remplacée par colorScheme.error) et une signature de
paramètres positionnels/nommés incohérente sur un fake de test.
flutter pub get OK, build_runner OK, dart format appliqué, analyze
propre (mêmes infos préexistantes), 119/119 tests verts, build APK
debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:41:18 +02:00
parent b643988986
commit c0dbce275b
16 changed files with 3913 additions and 3 deletions

View File

@ -106,6 +106,135 @@ final class AuthUseCases {
}
}
final class SyncUseCases {
const SyncUseCases({
required this.tokenStore,
required this.remoteSyncApi,
required this.metadataRepository,
required this.mappingRepository,
required this.localChanges,
required this.clock,
required this.deviceId,
});
final AuthTokenStore tokenStore;
final RemoteSyncApi remoteSyncApi;
final SyncMetadataRepository metadataRepository;
final RemoteResourceMappingRepository mappingRepository;
final LocalSyncChangeRepository localChanges;
final Clock clock;
final String deviceId;
Future<SyncRunSummary> synchronize({required bool manual}) async {
final token = await tokenStore.readToken();
if (token == null) {
return const SyncRunSummary(
pushedChanges: 0,
pulledChanges: 0,
skipped: true,
message: 'Not connected.',
);
}
final previousMetadata = await metadataRepository.read();
final attemptAt = clock.now();
await metadataRepository.save(
previousMetadata.copyWith(
lastAttemptAt: attemptAt,
status: OnlineSyncStatus.syncing,
),
);
try {
final pendingChanges = await localChanges.listPendingChanges();
var pushedCount = 0;
if (pendingChanges.isNotEmpty) {
final pushResult = await remoteSyncApi.push(
deviceId: deviceId,
items: pendingChanges.map((change) => change.item).toList(),
token: token,
);
final changesByKey = {
for (final change in pendingChanges)
_syncKey(change.item.resourceType, change.item.clientId): change,
};
final syncedChangeLogIds = <String>[];
for (final result in pushResult.results) {
final change =
changesByKey[_syncKey(result.resourceType, result.clientId)];
if (change == null || result.status == RemoteSyncPushStatus.error) {
continue;
}
pushedCount += result.status == RemoteSyncPushStatus.accepted ? 1 : 0;
syncedChangeLogIds.addAll(change.changeLogIds);
if (result.serverId != null && result.serverUpdatedAt != null) {
await mappingRepository.save(
RemoteResourceMapping(
resourceType: result.resourceType,
clientId: result.clientId,
serverId: result.serverId!,
serverUpdatedAt: result.serverUpdatedAt!,
),
);
}
}
if (syncedChangeLogIds.isNotEmpty) {
await localChanges.markChangesSynced(syncedChangeLogIds, clock.now());
}
}
final metadataBeforePull = await metadataRepository.read();
final pullResult = await remoteSyncApi.pull(
since: metadataBeforePull.serverCursor,
token: token,
);
var pulledCount = 0;
for (final item in pullResult.items) {
await mappingRepository.save(
RemoteResourceMapping(
resourceType: item.resourceType,
clientId: item.clientId,
serverId: item.serverId,
serverUpdatedAt: item.serverUpdatedAt,
),
);
final applied = await localChanges.applyRemoteItem(item);
if (applied) {
pulledCount += 1;
}
}
final completedAt = clock.now();
await metadataRepository.save(
(await metadataRepository.read()).copyWith(
serverCursor: pullResult.serverCursor,
lastSuccessfulSyncAt: completedAt,
lastFailureAt: null,
status: OnlineSyncStatus.success,
pendingPushCount: 0,
),
);
return SyncRunSummary(
pushedChanges: pushedCount,
pulledChanges: pulledCount,
);
} catch (error) {
await metadataRepository.save(
(await metadataRepository.read()).copyWith(
lastFailureAt: clock.now(),
status: OnlineSyncStatus.failure,
),
);
return SyncRunSummary(
pushedChanges: 0,
pulledChanges: 0,
failed: true,
message: error.toString(),
);
}
}
}
final class ExerciseUseCases {
const ExerciseUseCases({
required this.repository,
@ -2509,6 +2638,10 @@ String _positionKey(int programIndex, int exerciseIndex, int setIndex) {
return '$programIndex:$exerciseIndex:$setIndex';
}
String _syncKey(SyncResourceType resourceType, String clientId) {
return '${resourceType.name}:$clientId';
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
return switch (value) {
'stopwatch' => ScoreInputMode.stopwatch,