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

@ -168,6 +168,321 @@ final class DriftOnlineAccountRepository implements OnlineAccountRepository {
}
}
final class DriftSyncMetadataRepository implements SyncMetadataRepository {
const DriftSyncMetadataRepository(this.database);
static const _id = 'singleton';
final db.AppDatabase database;
@override
Future<SyncMetadataSnapshot> read() async {
final row = await (database.select(
database.syncMetadataEntries,
)..where((table) => table.id.equals(_id))).getSingleOrNull();
if (row == null) {
return const SyncMetadataSnapshot();
}
return SyncMetadataSnapshot(
serverCursor: row.serverCursor,
lastSuccessfulSyncAt: _utcOrNull(row.lastSuccessfulSyncAt),
lastAttemptAt: _utcOrNull(row.lastAttemptAt),
lastFailureAt: _utcOrNull(row.lastFailureAt),
status: _onlineSyncStatusFromDb(row.status),
pendingPushCount: row.pendingPushCount,
);
}
@override
Future<void> save(SyncMetadataSnapshot metadata) async {
await database
.into(database.syncMetadataEntries)
.insertOnConflictUpdate(
db.SyncMetadataEntriesCompanion.insert(
id: const Value(_id),
serverCursor: Value<String?>(metadata.serverCursor),
lastSuccessfulSyncAt: Value<DateTime?>(
_utcOrNull(metadata.lastSuccessfulSyncAt),
),
lastAttemptAt: Value<DateTime?>(_utcOrNull(metadata.lastAttemptAt)),
lastFailureAt: Value<DateTime?>(_utcOrNull(metadata.lastFailureAt)),
status: Value(_onlineSyncStatusToDb(metadata.status)),
pendingPushCount: Value<int?>(metadata.pendingPushCount),
),
);
}
}
final class DriftRemoteResourceMappingRepository
implements RemoteResourceMappingRepository {
const DriftRemoteResourceMappingRepository(this.database);
final db.AppDatabase database;
@override
Future<RemoteResourceMapping?> find({
required SyncResourceType resourceType,
required String clientId,
}) async {
final row =
await (database.select(database.remoteResourceMappings)..where(
(table) =>
table.resourceType.equals(
_syncResourceTypeToDb(resourceType),
) &
table.clientId.equals(clientId),
))
.getSingleOrNull();
return row == null ? null : _remoteResourceMappingFromRow(row);
}
@override
Future<void> save(RemoteResourceMapping mapping) async {
await database
.into(database.remoteResourceMappings)
.insertOnConflictUpdate(_remoteResourceMappingCompanion(mapping));
}
}
final class DriftLocalSyncChangeRepository
implements LocalSyncChangeRepository {
const DriftLocalSyncChangeRepository(this.database);
final db.AppDatabase database;
@override
Future<List<PendingSyncChange>> listPendingChanges() async {
final rows =
await (database.select(database.changeLogEntries)
..where(
(table) =>
table.syncedAt.isNull() &
table.entityType.isIn(_syncableEntityTypes),
)
..orderBy([(table) => OrderingTerm.asc(table.createdAt)]))
.get();
final grouped = <String, List<db.ChangeLogEntry>>{};
for (final row in rows) {
grouped
.putIfAbsent('${row.entityType}:${row.entityId}', () => [])
.add(row);
}
final changes = <PendingSyncChange>[];
for (final group in grouped.values) {
group.sort((left, right) => left.createdAt.compareTo(right.createdAt));
final latest = group.last;
final item = await _pushItemForChange(latest);
if (item == null) {
continue;
}
changes.add(
PendingSyncChange(
changeLogIds: group.map((row) => row.id).toList(),
item: item,
),
);
}
return changes;
}
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {
if (changeLogIds.isEmpty) {
return;
}
await (database.update(
database.changeLogEntries,
)..where((table) => table.id.isIn(changeLogIds))).write(
db.ChangeLogEntriesCompanion(
syncedAt: Value<DateTime?>(syncedAt.toUtc()),
),
);
}
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async {
final localUpdatedAt = await _localUpdatedAt(
item.resourceType,
item.clientId,
);
if (localUpdatedAt != null &&
!item.clientUpdatedAt.isAfter(localUpdatedAt)) {
return false;
}
if (item.deletedAt != null) {
return _applyRemoteDelete(item);
}
return _applyRemotePayload(item);
}
Future<RemoteSyncPushItem?> _pushItemForChange(
db.ChangeLogEntry change,
) async {
final resourceType = _syncResourceTypeFromEntityType(change.entityType);
if (resourceType == null) {
return null;
}
final snapshot = await _localSnapshot(resourceType, change.entityId);
return RemoteSyncPushItem(
resourceType: resourceType,
clientId: change.entityId,
schemaVersion: snapshot.schemaVersion,
clientUpdatedAt: snapshot.updatedAt,
deletedAt: snapshot.deletedAt,
payload: snapshot.payload,
);
}
Future<_LocalSyncSnapshot> _localSnapshot(
SyncResourceType resourceType,
String id,
) async {
return switch (resourceType) {
SyncResourceType.exercise => _exerciseSnapshot(id),
SyncResourceType.program => _programSnapshot(id),
SyncResourceType.workoutTemplate => _workoutTemplateSnapshot(id),
SyncResourceType.workoutHistory => _workoutHistorySnapshot(id),
SyncResourceType.mediaAsset => _mediaAssetSnapshot(id),
};
}
Future<DateTime?> _localUpdatedAt(
SyncResourceType resourceType,
String id,
) async {
final tableName = _tableNameForResourceType(resourceType);
final row = await database
.customSelect(
'SELECT updated_at FROM $tableName WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
final value = row?.data['updated_at'];
return value is DateTime ? value.toUtc() : null;
}
Future<bool> _applyRemoteDelete(RemoteSyncedItem item) async {
final tableName = _tableNameForResourceType(item.resourceType);
final updated = await database.customUpdate(
'UPDATE $tableName SET deleted_at = ?, updated_at = ?, '
"sync_state = 'deleted' WHERE id = ?",
variables: [
Variable<DateTime>(item.deletedAt!.toUtc()),
Variable<DateTime>(item.clientUpdatedAt.toUtc()),
Variable<String>(item.clientId),
],
);
return updated > 0;
}
Future<bool> _applyRemotePayload(RemoteSyncedItem item) async {
switch (item.resourceType) {
case SyncResourceType.exercise:
await database
.into(database.exercises)
.insertOnConflictUpdate(
_exerciseCompanion(_exerciseFromPayload(item)),
);
return true;
case SyncResourceType.mediaAsset:
await database
.into(database.mediaAssets)
.insertOnConflictUpdate(
_mediaAssetCompanion(_mediaAssetFromPayload(item)),
);
return true;
case SyncResourceType.program:
case SyncResourceType.workoutTemplate:
case SyncResourceType.workoutHistory:
return false;
}
}
Future<_LocalSyncSnapshot> _exerciseSnapshot(String id) async {
final row = await (database.select(
database.exercises,
)..where((table) => table.id.equals(id))).getSingle();
final exercise = _exerciseFromRow(
row,
await DriftExerciseRepository(database)._imageMediaIdsForExercise(id),
await DriftExerciseRepository(database)._stepsForExercise(id),
);
return _LocalSyncSnapshot.fromMetadata(
exercise.metadata,
_exercisePayload(exercise),
);
}
Future<_LocalSyncSnapshot> _mediaAssetSnapshot(String id) async {
final row = await (database.select(
database.mediaAssets,
)..where((table) => table.id.equals(id))).getSingle();
final asset = _mediaAssetFromRow(row);
return _LocalSyncSnapshot.fromMetadata(
asset.metadata,
_mediaAssetPayload(asset),
);
}
Future<_LocalSyncSnapshot> _programSnapshot(String id) async {
final program = await DriftProgramRepository(database).findById(id);
if (program == null) {
return _deletedFallbackSnapshot(SyncResourceType.program, id);
}
return _LocalSyncSnapshot.fromMetadata(
program.metadata,
_programPayload(program),
);
}
Future<_LocalSyncSnapshot> _workoutTemplateSnapshot(String id) async {
final template = await DriftWorkoutTemplateRepository(
database,
).findById(id);
if (template == null) {
return _deletedFallbackSnapshot(SyncResourceType.workoutTemplate, id);
}
return _LocalSyncSnapshot.fromMetadata(
template.metadata,
_workoutTemplatePayload(template),
);
}
Future<_LocalSyncSnapshot> _workoutHistorySnapshot(String id) async {
final history = await DriftWorkoutHistoryRepository(database).findById(id);
if (history == null) {
return _deletedFallbackSnapshot(SyncResourceType.workoutHistory, id);
}
return _LocalSyncSnapshot.fromMetadata(
history.metadata,
_workoutHistoryPayload(history),
);
}
Future<_LocalSyncSnapshot> _deletedFallbackSnapshot(
SyncResourceType resourceType,
String id,
) async {
final tableName = _tableNameForResourceType(resourceType);
final row = await database
.customSelect(
'SELECT updated_at, deleted_at, schema_version FROM $tableName '
'WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingle();
return _LocalSyncSnapshot(
schemaVersion: row.data['schema_version'] as int? ?? 1,
updatedAt: (row.data['updated_at'] as DateTime).toUtc(),
deletedAt: (row.data['deleted_at'] as DateTime?)?.toUtc(),
payload: {'id': id},
);
}
}
final class DriftProgramRepository implements ProgramRepository {
const DriftProgramRepository(this.database);
@ -2306,6 +2621,303 @@ domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow(
);
}
db.RemoteResourceMappingsCompanion _remoteResourceMappingCompanion(
RemoteResourceMapping mapping,
) {
return db.RemoteResourceMappingsCompanion.insert(
id: _remoteResourceMappingId(mapping.resourceType, mapping.clientId),
resourceType: _syncResourceTypeToDb(mapping.resourceType),
clientId: mapping.clientId,
serverId: mapping.serverId,
serverUpdatedAt: mapping.serverUpdatedAt.toUtc(),
);
}
RemoteResourceMapping _remoteResourceMappingFromRow(
db.RemoteResourceMapping row,
) {
return RemoteResourceMapping(
resourceType: _syncResourceTypeFromDb(row.resourceType),
clientId: row.clientId,
serverId: row.serverId,
serverUpdatedAt: _utc(row.serverUpdatedAt),
);
}
String _remoteResourceMappingId(
SyncResourceType resourceType,
String clientId,
) {
return '${_syncResourceTypeToDb(resourceType)}:$clientId';
}
final class _LocalSyncSnapshot {
const _LocalSyncSnapshot({
required this.schemaVersion,
required this.updatedAt,
required this.deletedAt,
required this.payload,
});
factory _LocalSyncSnapshot.fromMetadata(
domain.EntityMetadata metadata,
Map<String, Object?> payload,
) {
return _LocalSyncSnapshot(
schemaVersion: metadata.schemaVersion,
updatedAt: metadata.updatedAt.toUtc(),
deletedAt: _utcOrNull(metadata.deletedAt),
payload: payload,
);
}
final int schemaVersion;
final DateTime updatedAt;
final DateTime? deletedAt;
final Map<String, Object?> payload;
}
Map<String, Object?> _metadataPayload(domain.EntityMetadata metadata) => {
'id': metadata.id,
'createdAt': metadata.createdAt.toUtc().toIso8601String(),
'updatedAt': metadata.updatedAt.toUtc().toIso8601String(),
'deletedAt': metadata.deletedAt?.toUtc().toIso8601String(),
'schemaVersion': metadata.schemaVersion,
'syncState': metadata.syncState.name,
'localRevision': metadata.localRevision,
'originDeviceId': metadata.originDeviceId,
};
Map<String, Object?> _exercisePayload(domain.Exercise exercise) => {
'metadata': _metadataPayload(exercise.metadata),
'id': exercise.metadata.id,
'name': exercise.name,
'description': exercise.description,
'imageMediaIds': exercise.imageMediaIds,
'iconMediaId': exercise.iconMediaId,
'videoMediaId': exercise.videoMediaId,
'hasTimeMeasure': exercise.hasTimeMeasure,
'hasRepsMeasure': exercise.hasRepsMeasure,
'hasScoreMeasure': exercise.hasScoreMeasure,
'scoreInputMode': exercise.scoreInputMode.name,
'scoreLabel': exercise.scoreLabel,
'scoreUnit': exercise.scoreUnit,
'defaultTargetTimeSeconds': exercise.defaultTargetTimeSeconds,
'defaultTargetReps': exercise.defaultTargetReps,
'defaultTargetScore': exercise.defaultTargetScore,
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
'archivedAt': exercise.archivedAt?.toUtc().toIso8601String(),
};
Map<String, Object?> _mediaAssetPayload(domain.MediaAsset asset) => {
'metadata': _metadataPayload(asset.metadata),
'id': asset.metadata.id,
'kind': _mediaKindToDb(asset.kind),
'localUri': asset.localUri,
'mimeType': asset.mimeType,
'sizeBytes': asset.sizeBytes,
'width': asset.width,
'height': asset.height,
'durationMs': asset.durationMs,
'checksum': asset.checksum,
'remoteUri': asset.remoteUri,
'thumbnailLocalUri': asset.thumbnailLocalUri,
};
Map<String, Object?> _programPayload(domain.Program program) => {
'metadata': _metadataPayload(program.metadata),
'id': program.metadata.id,
'name': program.name,
'defaultRestSeconds': program.defaultRestSeconds,
'exercises': program.exercises
.map((exercise) => exercise.toSnapshotJson())
.toList(),
};
Map<String, Object?> _workoutTemplatePayload(domain.WorkoutTemplate template) =>
{
'metadata': _metadataPayload(template.metadata),
'id': template.metadata.id,
'name': template.name,
'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(),
'programs': template.programs
.map(
(program) => {
'id': program.metadata.id,
'sourceProgramId': program.sourceProgramId,
'position': program.position,
'programNameSnapshot': program.programNameSnapshot,
'defaultRestSecondsSnapshot': program.defaultRestSecondsSnapshot,
'programSnapshotJson': program.programSnapshotJson,
},
)
.toList(),
'overrides': template.overrides
.map(
(override) => {
'id': override.metadata.id,
'workoutTemplateProgramId': override.workoutTemplateProgramId,
'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
'setsCountOverride': override.setsCountOverride,
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
'targetRepsOverride': override.targetRepsOverride,
'targetScoreOverride': override.targetScoreOverride,
'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
},
)
.toList(),
};
Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'metadata': _metadataPayload(history.metadata),
'id': history.metadata.id,
'sourceWorkoutTemplateId': history.sourceWorkoutTemplateId,
'sourceActiveWorkoutSessionId': history.sourceActiveWorkoutSessionId,
'nameSnapshot': history.nameSnapshot,
'startedAt': history.startedAt.toUtc().toIso8601String(),
'endedAt': history.endedAt.toUtc().toIso8601String(),
'totalActiveMs': history.totalActiveMs,
'completed': history.completed,
'historySnapshotJson': history.historySnapshotJson,
};
domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
return domain.Exercise(
metadata: _metadataFromPayload(item),
name: _stringFromPayload(payload, 'name', item.clientId),
description: payload['description'] as String?,
imageMediaIds: _stringListFromPayload(payload['imageMediaIds']),
iconMediaId: payload['iconMediaId'] as String?,
videoMediaId: payload['videoMediaId'] as String?,
hasTimeMeasure: payload['hasTimeMeasure'] == true,
hasRepsMeasure: payload['hasRepsMeasure'] == true,
hasScoreMeasure: payload['hasScoreMeasure'] == true,
scoreInputMode: _scoreInputModeFromDb(
payload['scoreInputMode'] as String? ?? 'manual',
),
scoreLabel: payload['scoreLabel'] as String?,
scoreUnit: payload['scoreUnit'] as String?,
defaultTargetTimeSeconds: payload['defaultTargetTimeSeconds'] as int?,
defaultTargetReps: payload['defaultTargetReps'] as int?,
defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?,
steps: _stepsFromPayload(payload['steps']),
archivedAt: _dateTimeFromPayload(payload['archivedAt']),
);
}
domain.MediaAsset _mediaAssetFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
return domain.MediaAsset(
metadata: _metadataFromPayload(item),
kind: _mediaKindFromDb(payload['kind'] as String? ?? 'image'),
localUri: _stringFromPayload(payload, 'localUri', ''),
mimeType: payload['mimeType'] as String?,
sizeBytes: payload['sizeBytes'] as int?,
width: payload['width'] as int?,
height: payload['height'] as int?,
durationMs: payload['durationMs'] as int?,
checksum: payload['checksum'] as String?,
remoteUri: payload['remoteUri'] as String?,
thumbnailLocalUri: payload['thumbnailLocalUri'] as String?,
);
}
domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) {
final metadata = item.payload['metadata'];
final map = metadata is Map ? Map<String, Object?>.from(metadata) : null;
return domain.EntityMetadata(
id: item.clientId,
createdAt: _dateTimeFromPayload(map?['createdAt']) ?? item.clientUpdatedAt,
updatedAt: item.clientUpdatedAt,
deletedAt: item.deletedAt,
schemaVersion: item.schemaVersion,
syncState: item.deletedAt == null
? domain.SyncState.synced
: domain.SyncState.deleted,
localRevision: map?['localRevision'] as int? ?? 0,
originDeviceId: map?['originDeviceId'] as String? ?? 'remote',
);
}
String _stringFromPayload(
Map<String, Object?> payload,
String key,
String fallback,
) {
final value = payload[key];
return value is String && value.trim().isNotEmpty ? value : fallback;
}
List<String> _stringListFromPayload(Object? value) {
if (value is! List) {
return const [];
}
return value.whereType<String>().toList(growable: false);
}
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
if (value is! List) {
return const [];
}
return value
.map((raw) {
final json = Map<String, Object?>.from(raw as Map);
return domain.ExerciseStep(
id: _requiredString(json, 'id'),
position: _requiredInt(json, 'position'),
name: _requiredString(json, 'name'),
type: _exerciseStepTypeFromDb(_requiredString(json, 'type')),
defaultTargetValue: _requiredInt(json, 'defaultTargetValue'),
hasScore: _requiredBool(json, 'hasScore'),
scoreInputMode: _scoreInputModeFromDb(
_optionalString(json, 'scoreInputMode') ?? 'manual',
),
scoreLabel: _optionalString(json, 'scoreLabel'),
scoreUnit: _optionalString(json, 'scoreUnit'),
defaultTargetScore: _optionalDouble(json, 'defaultTargetScore'),
defaultTargetScoreTimeMs: _optionalInt(
json,
'defaultTargetScoreTimeMs',
),
);
})
.toList(growable: false);
}
DateTime? _dateTimeFromPayload(Object? value) {
return value is String ? DateTime.tryParse(value)?.toUtc() : null;
}
const _syncableEntityTypes = [
'Exercise',
'Program',
'WorkoutTemplate',
'WorkoutHistory',
'MediaAsset',
];
SyncResourceType? _syncResourceTypeFromEntityType(String entityType) {
return switch (entityType) {
'Exercise' => SyncResourceType.exercise,
'Program' => SyncResourceType.program,
'WorkoutTemplate' => SyncResourceType.workoutTemplate,
'WorkoutHistory' => SyncResourceType.workoutHistory,
'MediaAsset' => SyncResourceType.mediaAsset,
_ => null,
};
}
String _tableNameForResourceType(SyncResourceType type) => switch (type) {
SyncResourceType.exercise => 'exercises',
SyncResourceType.program => 'programs',
SyncResourceType.workoutTemplate => 'workout_templates',
SyncResourceType.workoutHistory => 'workout_history',
SyncResourceType.mediaAsset => 'media_assets',
};
String _syncStateToDb(domain.SyncState state) => switch (state) {
domain.SyncState.localOnly => 'localOnly',
domain.SyncState.dirty => 'dirty',
@ -2321,6 +2933,38 @@ domain.SyncState _syncStateFromDb(String value) => switch (value) {
_ => throw domain.DomainException('Unknown sync state: $value'),
};
String _syncResourceTypeToDb(SyncResourceType type) => switch (type) {
SyncResourceType.exercise => 'exercise',
SyncResourceType.program => 'program',
SyncResourceType.workoutTemplate => 'workoutTemplate',
SyncResourceType.workoutHistory => 'workoutHistory',
SyncResourceType.mediaAsset => 'mediaAsset',
};
SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) {
'exercise' => SyncResourceType.exercise,
'program' => SyncResourceType.program,
'workoutTemplate' => SyncResourceType.workoutTemplate,
'workoutHistory' => SyncResourceType.workoutHistory,
'mediaAsset' => SyncResourceType.mediaAsset,
_ => throw domain.DomainException('Unknown sync resource type: $value'),
};
String _onlineSyncStatusToDb(OnlineSyncStatus status) => switch (status) {
OnlineSyncStatus.idle => 'idle',
OnlineSyncStatus.syncing => 'syncing',
OnlineSyncStatus.success => 'success',
OnlineSyncStatus.failure => 'failure',
};
OnlineSyncStatus _onlineSyncStatusFromDb(String value) => switch (value) {
'idle' => OnlineSyncStatus.idle,
'syncing' => OnlineSyncStatus.syncing,
'success' => OnlineSyncStatus.success,
'failure' => OnlineSyncStatus.failure,
_ => throw domain.DomainException('Unknown sync status: $value'),
};
String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) {
domain.ActiveWorkoutStatus.running => 'running',
domain.ActiveWorkoutStatus.paused => 'paused',