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:
@ -13,6 +13,7 @@ abstract interface class AppDependencies {
|
||||
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
|
||||
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||
SyncUseCases get syncUseCases;
|
||||
}
|
||||
|
||||
final class AppBootstrap implements AppDependencies {
|
||||
@ -27,6 +28,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
required this.activeExerciseStepUseCases,
|
||||
required this.closeWorkoutSessionUseCase,
|
||||
required this.workoutHistoryUseCases,
|
||||
required this.syncUseCases,
|
||||
required this.syncGateway,
|
||||
});
|
||||
|
||||
@ -49,6 +51,8 @@ final class AppBootstrap implements AppDependencies {
|
||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||
@override
|
||||
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
||||
@override
|
||||
final SyncUseCases syncUseCases;
|
||||
final SyncGateway syncGateway;
|
||||
|
||||
static Future<AppBootstrap> create() async {
|
||||
@ -57,6 +61,9 @@ final class AppBootstrap implements AppDependencies {
|
||||
final exerciseRepository = DriftExerciseRepository(database);
|
||||
final mediaRepository = DriftMediaAssetRepository(database);
|
||||
final onlineAccountRepository = DriftOnlineAccountRepository(database);
|
||||
final syncMetadataRepository = DriftSyncMetadataRepository(database);
|
||||
final mappingRepository = DriftRemoteResourceMappingRepository(database);
|
||||
final localSyncChangeRepository = DriftLocalSyncChangeRepository(database);
|
||||
final programRepository = DriftProgramRepository(database);
|
||||
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||
final activeSessionRepository = DriftActiveSessionRepository(database);
|
||||
@ -67,6 +74,9 @@ final class AppBootstrap implements AppDependencies {
|
||||
final remoteAuthApi = HttpRemoteAuthApi(
|
||||
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
|
||||
);
|
||||
final remoteSyncApi = HttpRemoteSyncApi(
|
||||
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
|
||||
);
|
||||
|
||||
return AppBootstrap._(
|
||||
database: database,
|
||||
@ -131,6 +141,15 @@ final class AppBootstrap implements AppDependencies {
|
||||
repository: historyRepository,
|
||||
clock: clock,
|
||||
),
|
||||
syncUseCases: SyncUseCases(
|
||||
tokenStore: const SecureStorageAuthTokenStore(),
|
||||
remoteSyncApi: remoteSyncApi,
|
||||
metadataRepository: syncMetadataRepository,
|
||||
mappingRepository: mappingRepository,
|
||||
localChanges: localSyncChangeRepository,
|
||||
clock: clock,
|
||||
deviceId: originDeviceId,
|
||||
),
|
||||
syncGateway: const NoOpSyncGateway(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -73,6 +73,194 @@ abstract interface class RemoteAuthApi {
|
||||
Future<void> logout(String token);
|
||||
}
|
||||
|
||||
enum SyncResourceType {
|
||||
exercise,
|
||||
program,
|
||||
workoutTemplate,
|
||||
workoutHistory,
|
||||
mediaAsset,
|
||||
}
|
||||
|
||||
enum RemoteSyncPushStatus { accepted, ignoredOlder, error }
|
||||
|
||||
enum OnlineSyncStatus { idle, syncing, success, failure }
|
||||
|
||||
final class RemoteSyncPushItem {
|
||||
const RemoteSyncPushItem({
|
||||
required this.resourceType,
|
||||
required this.clientId,
|
||||
required this.schemaVersion,
|
||||
required this.clientUpdatedAt,
|
||||
required this.deletedAt,
|
||||
required this.payload,
|
||||
});
|
||||
|
||||
final SyncResourceType resourceType;
|
||||
final String clientId;
|
||||
final int schemaVersion;
|
||||
final DateTime clientUpdatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final Map<String, Object?> payload;
|
||||
}
|
||||
|
||||
final class RemoteSyncPushItemResult {
|
||||
const RemoteSyncPushItemResult({
|
||||
required this.resourceType,
|
||||
required this.clientId,
|
||||
this.serverId,
|
||||
required this.status,
|
||||
this.serverUpdatedAt,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final SyncResourceType resourceType;
|
||||
final String clientId;
|
||||
final String? serverId;
|
||||
final RemoteSyncPushStatus status;
|
||||
final DateTime? serverUpdatedAt;
|
||||
final String? errorMessage;
|
||||
}
|
||||
|
||||
final class RemoteSyncPushResult {
|
||||
const RemoteSyncPushResult({
|
||||
required this.serverCursor,
|
||||
required this.results,
|
||||
});
|
||||
|
||||
final String? serverCursor;
|
||||
final List<RemoteSyncPushItemResult> results;
|
||||
}
|
||||
|
||||
final class RemoteSyncedItem {
|
||||
const RemoteSyncedItem({
|
||||
required this.resourceType,
|
||||
required this.clientId,
|
||||
required this.serverId,
|
||||
required this.schemaVersion,
|
||||
required this.clientUpdatedAt,
|
||||
required this.serverUpdatedAt,
|
||||
required this.deletedAt,
|
||||
required this.payload,
|
||||
});
|
||||
|
||||
final SyncResourceType resourceType;
|
||||
final String clientId;
|
||||
final String serverId;
|
||||
final int schemaVersion;
|
||||
final DateTime clientUpdatedAt;
|
||||
final DateTime serverUpdatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final Map<String, Object?> payload;
|
||||
}
|
||||
|
||||
final class RemoteSyncPullResult {
|
||||
const RemoteSyncPullResult({required this.serverCursor, required this.items});
|
||||
|
||||
final String? serverCursor;
|
||||
final List<RemoteSyncedItem> items;
|
||||
}
|
||||
|
||||
abstract interface class RemoteSyncApi {
|
||||
Future<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
});
|
||||
|
||||
Future<RemoteSyncPullResult> pull({
|
||||
required String? since,
|
||||
required String token,
|
||||
});
|
||||
}
|
||||
|
||||
final class SyncMetadataSnapshot {
|
||||
const SyncMetadataSnapshot({
|
||||
this.serverCursor,
|
||||
this.lastSuccessfulSyncAt,
|
||||
this.lastAttemptAt,
|
||||
this.lastFailureAt,
|
||||
this.status = OnlineSyncStatus.idle,
|
||||
this.pendingPushCount,
|
||||
});
|
||||
|
||||
final String? serverCursor;
|
||||
final DateTime? lastSuccessfulSyncAt;
|
||||
final DateTime? lastAttemptAt;
|
||||
final DateTime? lastFailureAt;
|
||||
final OnlineSyncStatus status;
|
||||
final int? pendingPushCount;
|
||||
|
||||
SyncMetadataSnapshot copyWith({
|
||||
Object? serverCursor = _portsUnchanged,
|
||||
Object? lastSuccessfulSyncAt = _portsUnchanged,
|
||||
Object? lastAttemptAt = _portsUnchanged,
|
||||
Object? lastFailureAt = _portsUnchanged,
|
||||
OnlineSyncStatus? status,
|
||||
Object? pendingPushCount = _portsUnchanged,
|
||||
}) {
|
||||
return SyncMetadataSnapshot(
|
||||
serverCursor: serverCursor == _portsUnchanged
|
||||
? this.serverCursor
|
||||
: serverCursor as String?,
|
||||
lastSuccessfulSyncAt: lastSuccessfulSyncAt == _portsUnchanged
|
||||
? this.lastSuccessfulSyncAt
|
||||
: lastSuccessfulSyncAt as DateTime?,
|
||||
lastAttemptAt: lastAttemptAt == _portsUnchanged
|
||||
? this.lastAttemptAt
|
||||
: lastAttemptAt as DateTime?,
|
||||
lastFailureAt: lastFailureAt == _portsUnchanged
|
||||
? this.lastFailureAt
|
||||
: lastFailureAt as DateTime?,
|
||||
status: status ?? this.status,
|
||||
pendingPushCount: pendingPushCount == _portsUnchanged
|
||||
? this.pendingPushCount
|
||||
: pendingPushCount as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteResourceMapping {
|
||||
const RemoteResourceMapping({
|
||||
required this.resourceType,
|
||||
required this.clientId,
|
||||
required this.serverId,
|
||||
required this.serverUpdatedAt,
|
||||
});
|
||||
|
||||
final SyncResourceType resourceType;
|
||||
final String clientId;
|
||||
final String serverId;
|
||||
final DateTime serverUpdatedAt;
|
||||
}
|
||||
|
||||
final class PendingSyncChange {
|
||||
const PendingSyncChange({required this.changeLogIds, required this.item});
|
||||
|
||||
final List<String> changeLogIds;
|
||||
final RemoteSyncPushItem item;
|
||||
}
|
||||
|
||||
abstract interface class SyncMetadataRepository {
|
||||
Future<SyncMetadataSnapshot> read();
|
||||
Future<void> save(SyncMetadataSnapshot metadata);
|
||||
}
|
||||
|
||||
abstract interface class RemoteResourceMappingRepository {
|
||||
Future<RemoteResourceMapping?> find({
|
||||
required SyncResourceType resourceType,
|
||||
required String clientId,
|
||||
});
|
||||
Future<void> save(RemoteResourceMapping mapping);
|
||||
}
|
||||
|
||||
abstract interface class LocalSyncChangeRepository {
|
||||
Future<List<PendingSyncChange>> listPendingChanges();
|
||||
Future<void> markChangesSynced(List<String> changeLogIds, DateTime syncedAt);
|
||||
Future<bool> applyRemoteItem(RemoteSyncedItem item);
|
||||
}
|
||||
|
||||
const Object _portsUnchanged = Object();
|
||||
|
||||
abstract interface class ExerciseRepository {
|
||||
Future<Exercise?> findById(String id);
|
||||
Future<List<Exercise>> listActive();
|
||||
@ -189,10 +377,16 @@ final class SyncRunSummary {
|
||||
const SyncRunSummary({
|
||||
required this.pushedChanges,
|
||||
required this.pulledChanges,
|
||||
this.skipped = false,
|
||||
this.failed = false,
|
||||
this.message,
|
||||
});
|
||||
|
||||
final int pushedChanges;
|
||||
final int pulledChanges;
|
||||
final bool skipped;
|
||||
final bool failed;
|
||||
final String? message;
|
||||
}
|
||||
|
||||
abstract interface class SyncGateway {
|
||||
|
||||
@ -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