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

@ -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(),
);
}

View File

@ -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 {

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,