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