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:
@ -21,6 +21,8 @@ part 'app_database.g.dart';
|
||||
OnlineAccountSessions,
|
||||
ProgramExercises,
|
||||
Programs,
|
||||
RemoteResourceMappings,
|
||||
SyncMetadataEntries,
|
||||
WorkoutHistories,
|
||||
WorkoutHistorySetResults,
|
||||
WorkoutHistoryStepResults,
|
||||
@ -42,7 +44,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 10;
|
||||
int get schemaVersion => 11;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -87,6 +89,9 @@ final class AppDatabase extends _$AppDatabase {
|
||||
if (from < 10) {
|
||||
await _migrateToSchema10(migrator);
|
||||
}
|
||||
if (from < 11) {
|
||||
await _migrateToSchema11(migrator);
|
||||
}
|
||||
await _createIndexes();
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
@ -131,6 +136,10 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'CREATE INDEX IF NOT EXISTS idx_online_account_sessions_logged_in '
|
||||
'ON online_account_sessions (is_logged_in, updated_at)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource '
|
||||
'ON remote_resource_mappings (resource_type, client_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
|
||||
'ON workout_template_programs (workout_template_id)',
|
||||
@ -327,4 +336,9 @@ extension on AppDatabase {
|
||||
Future<void> _migrateToSchema10(Migrator migrator) async {
|
||||
await migrator.createTable(onlineAccountSessions);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema11(Migrator migrator) async {
|
||||
await migrator.createTable(syncMetadataEntries);
|
||||
await migrator.createTable(remoteResourceMappings);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
|
||||
@ -48,6 +48,50 @@ class OnlineAccountSessions extends Table {
|
||||
];
|
||||
}
|
||||
|
||||
class SyncMetadataEntries extends Table {
|
||||
@override
|
||||
String get tableName => 'sync_metadata';
|
||||
|
||||
TextColumn get id => text().withDefault(const Constant('singleton'))();
|
||||
TextColumn get serverCursor => text().nullable()();
|
||||
DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()();
|
||||
DateTimeColumn get lastAttemptAt => dateTime().nullable()();
|
||||
DateTimeColumn get lastFailureAt => dateTime().nullable()();
|
||||
TextColumn get status => text().withDefault(const Constant('idle'))();
|
||||
IntColumn get pendingPushCount => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (id = 'singleton')",
|
||||
"CHECK (status IN ('idle', 'syncing', 'success', 'failure'))",
|
||||
'CHECK (pending_push_count IS NULL OR pending_push_count >= 0)',
|
||||
];
|
||||
}
|
||||
|
||||
class RemoteResourceMappings extends Table {
|
||||
@override
|
||||
String get tableName => 'remote_resource_mappings';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get resourceType => text()();
|
||||
TextColumn get clientId => text().withLength(min: 1)();
|
||||
TextColumn get serverId => text().withLength(min: 1)();
|
||||
DateTimeColumn get serverUpdatedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (resource_type IN ('exercise', 'program', 'workoutTemplate', "
|
||||
"'workoutHistory', 'mediaAsset'))",
|
||||
'UNIQUE (resource_type, client_id)',
|
||||
];
|
||||
}
|
||||
|
||||
class MediaAssets extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'media_assets';
|
||||
|
||||
@ -68,12 +68,55 @@ final class HttpApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
Uri _resolve(String path) {
|
||||
Future<Map<String, Object?>> getJson(
|
||||
String path, {
|
||||
Map<String, String?> queryParameters = const {},
|
||||
String? bearerToken,
|
||||
Set<int> expectedStatuses = const {200},
|
||||
}) async {
|
||||
final response = await _send(
|
||||
() => client.get(
|
||||
_resolve(path, queryParameters: queryParameters),
|
||||
headers: _headers(bearerToken),
|
||||
),
|
||||
);
|
||||
if (!expectedStatuses.contains(response.statusCode)) {
|
||||
throw _exceptionForStatus(response.statusCode, response.body);
|
||||
}
|
||||
if (response.body.trim().isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} on FormatException catch (error) {
|
||||
throw RemoteAuthException(RemoteAuthFailure.unknown, error.message);
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return Map<String, Object?>.from(decoded);
|
||||
}
|
||||
throw const RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Unexpected JSON response.',
|
||||
);
|
||||
}
|
||||
|
||||
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
|
||||
final normalized = path.startsWith('/') ? path.substring(1) : path;
|
||||
final base = baseUrl.toString().endsWith('/')
|
||||
? baseUrl
|
||||
: Uri.parse('${baseUrl.toString()}/');
|
||||
return base.resolve(normalized);
|
||||
final resolved = base.resolve(normalized);
|
||||
final cleanQuery = {
|
||||
for (final entry in queryParameters.entries)
|
||||
if (entry.value != null) entry.key: entry.value!,
|
||||
};
|
||||
if (cleanQuery.isEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
return resolved.replace(
|
||||
queryParameters: {...resolved.queryParameters, ...cleanQuery},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _headers(String? bearerToken) => {
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export 'auth_api.dart';
|
||||
export 'http_api_client.dart';
|
||||
export 'sync_api.dart';
|
||||
|
||||
171
lib/infrastructure/remote/sync_api.dart
Normal file
171
lib/infrastructure/remote/sync_api.dart
Normal file
@ -0,0 +1,171 @@
|
||||
import '../../application/application.dart';
|
||||
import 'http_api_client.dart';
|
||||
|
||||
final class HttpRemoteSyncApi implements RemoteSyncApi {
|
||||
const HttpRemoteSyncApi(this.client);
|
||||
|
||||
final HttpApiClient client;
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
}) async {
|
||||
final response = await client.postJson(
|
||||
'/sync/push',
|
||||
bearerToken: token,
|
||||
body: {
|
||||
'deviceId': deviceId,
|
||||
'items': items.map(_pushItemToJson).toList(),
|
||||
},
|
||||
);
|
||||
return RemoteSyncPushResult(
|
||||
serverCursor: _optionalString(response, 'serverCursor'),
|
||||
results: (_list(
|
||||
response,
|
||||
'results',
|
||||
)).map((item) => _pushItemResultFromJson(_map(item))).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPullResult> pull({
|
||||
required String? since,
|
||||
required String token,
|
||||
}) async {
|
||||
final response = await client.getJson(
|
||||
'/sync/pull',
|
||||
bearerToken: token,
|
||||
queryParameters: {'since': since},
|
||||
);
|
||||
return RemoteSyncPullResult(
|
||||
serverCursor: _optionalString(response, 'serverCursor'),
|
||||
items: (_list(
|
||||
response,
|
||||
'items',
|
||||
)).map((item) => _syncedItemFromJson(_map(item))).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _pushItemToJson(RemoteSyncPushItem item) => {
|
||||
'resourceType': _resourceTypeToWire(item.resourceType),
|
||||
'clientId': item.clientId,
|
||||
'schemaVersion': item.schemaVersion,
|
||||
'clientUpdatedAt': item.clientUpdatedAt.toUtc().toIso8601String(),
|
||||
'deletedAt': item.deletedAt?.toUtc().toIso8601String(),
|
||||
'payload': item.payload,
|
||||
};
|
||||
|
||||
RemoteSyncPushItemResult _pushItemResultFromJson(Map<String, Object?> json) {
|
||||
return RemoteSyncPushItemResult(
|
||||
resourceType: _resourceTypeFromWire(
|
||||
_requiredString(json, 'resourceType'),
|
||||
),
|
||||
clientId: _requiredString(json, 'clientId'),
|
||||
serverId: _optionalString(json, 'serverId'),
|
||||
status: _pushStatusFromWire(_requiredString(json, 'status')),
|
||||
serverUpdatedAt: _optionalDateTime(json, 'serverUpdatedAt'),
|
||||
errorMessage: _optionalString(json, 'message'),
|
||||
);
|
||||
}
|
||||
|
||||
RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) {
|
||||
return RemoteSyncedItem(
|
||||
resourceType: _resourceTypeFromWire(
|
||||
_requiredString(json, 'resourceType'),
|
||||
),
|
||||
clientId: _requiredString(json, 'clientId'),
|
||||
serverId: _requiredString(json, 'serverId'),
|
||||
schemaVersion: _requiredInt(json, 'schemaVersion'),
|
||||
clientUpdatedAt: _requiredDateTime(json, 'clientUpdatedAt'),
|
||||
serverUpdatedAt: _requiredDateTime(json, 'serverUpdatedAt'),
|
||||
deletedAt: _optionalDateTime(json, 'deletedAt'),
|
||||
payload: _map(json['payload']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _resourceTypeToWire(SyncResourceType type) => switch (type) {
|
||||
SyncResourceType.exercise => 'exercise',
|
||||
SyncResourceType.program => 'program',
|
||||
SyncResourceType.workoutTemplate => 'workoutTemplate',
|
||||
SyncResourceType.workoutHistory => 'workoutHistory',
|
||||
SyncResourceType.mediaAsset => 'mediaAsset',
|
||||
};
|
||||
|
||||
SyncResourceType _resourceTypeFromWire(String value) => switch (value) {
|
||||
'exercise' => SyncResourceType.exercise,
|
||||
'program' => SyncResourceType.program,
|
||||
'workoutTemplate' => SyncResourceType.workoutTemplate,
|
||||
'workoutHistory' => SyncResourceType.workoutHistory,
|
||||
'mediaAsset' => SyncResourceType.mediaAsset,
|
||||
_ => throw RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Unknown sync resource type: $value',
|
||||
),
|
||||
};
|
||||
|
||||
RemoteSyncPushStatus _pushStatusFromWire(String value) => switch (value) {
|
||||
'accepted' => RemoteSyncPushStatus.accepted,
|
||||
'ignoredOlder' => RemoteSyncPushStatus.ignoredOlder,
|
||||
'error' => RemoteSyncPushStatus.error,
|
||||
_ => throw RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Unknown sync push status: $value',
|
||||
),
|
||||
};
|
||||
|
||||
List<Object?> _list(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is List) {
|
||||
return value.cast<Object?>();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
Map<String, Object?> _map(Object? value) {
|
||||
if (value is Map) {
|
||||
return Map<String, Object?>.from(value);
|
||||
}
|
||||
throw const RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Expected JSON object.',
|
||||
);
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'$key is missing from sync response.',
|
||||
);
|
||||
}
|
||||
|
||||
String? _optionalString(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
return value is String && value.trim().isNotEmpty ? value : null;
|
||||
}
|
||||
|
||||
int _requiredInt(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
throw RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'$key is missing from sync response.',
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _requiredDateTime(Map<String, Object?> json, String key) {
|
||||
return DateTime.parse(_requiredString(json, key)).toUtc();
|
||||
}
|
||||
|
||||
DateTime? _optionalDateTime(Map<String, Object?> json, String key) {
|
||||
final value = _optionalString(json, key);
|
||||
return value == null ? null : DateTime.parse(value).toUtc();
|
||||
}
|
||||
Reference in New Issue
Block a user