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