feat: add local backup export import core

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:23:30 +02:00
parent b256595e08
commit c3d5273869
6 changed files with 1380 additions and 4 deletions

View File

@ -1602,6 +1602,390 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
}
}
final class DriftLocalDataBackupRepository
implements LocalDataBackupRepository {
const DriftLocalDataBackupRepository(this.database);
final db.AppDatabase database;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
final mediaAssets = await DriftMediaAssetRepository(database).listActive();
final exercises = await DriftExerciseRepository(database).listActive();
final programs = await DriftProgramRepository(database).listActive();
final templates = await DriftWorkoutTemplateRepository(
database,
).listActive();
final histories = await DriftWorkoutHistoryRepository(
database,
).listActive();
return LocalDataExportSnapshot(
exportedAt: exportedAt.toUtc(),
appSchemaVersion: database.schemaVersion,
originDeviceId: 'local-device',
mediaAssets: [
for (final asset in mediaAssets)
_backupResource(asset.metadata, _mediaAssetPayload(asset)),
],
exercises: [
for (final exercise in exercises)
_backupResource(exercise.metadata, _exercisePayload(exercise)),
],
programs: [
for (final program in programs)
_backupResource(program.metadata, _programPayload(program)),
],
workoutTemplates: [
for (final template in templates)
_backupResource(template.metadata, _workoutTemplatePayload(template)),
],
workoutHistories: [
for (final history in histories)
_backupResource(
history.metadata,
_localWorkoutHistoryPayload(history),
),
],
);
}
@override
Future<bool> hasOpenActiveWorkoutSession() async {
return DriftActiveSessionRepository(
database,
).findOpen().then((session) => session != null);
}
@override
Future<bool> hasAnyUserData() async {
final count = await database.customSelect('''
SELECT
(SELECT COUNT(*) FROM media_assets WHERE deleted_at IS NULL) +
(SELECT COUNT(*) FROM exercises WHERE deleted_at IS NULL AND archived_at IS NULL) +
(SELECT COUNT(*) FROM programs WHERE deleted_at IS NULL) +
(SELECT COUNT(*) FROM workout_templates WHERE deleted_at IS NULL) +
(SELECT COUNT(*) FROM workout_history WHERE deleted_at IS NULL) AS total
''').getSingle();
return count.read<int>('total') > 0;
}
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
if (await hasOpenActiveWorkoutSession()) {
throw const LocalBackupException(
LocalBackupValidationError.activeWorkoutInProgress,
);
}
var inserted = 0;
var updated = 0;
var ignored = 0;
var deleted = 0;
await database.transaction(() async {
if (mode == LocalBackupImportMode.replaceAll) {
deleted += await _softDeleteResourcesAbsentFromBackup(
snapshot,
importedAt,
);
}
final force = mode == LocalBackupImportMode.replaceAll;
final mediaResult = await _applyBackupResources(
resources: snapshot.mediaAssets,
resourceType: SyncResourceType.mediaAsset,
tableName: 'media_assets',
importedAt: importedAt,
force: force,
);
inserted += mediaResult.insertedCount;
updated += mediaResult.updatedCount;
ignored += mediaResult.ignoredOlderCount;
final exerciseResult = await _applyBackupResources(
resources: snapshot.exercises,
resourceType: SyncResourceType.exercise,
tableName: 'exercises',
importedAt: importedAt,
force: force,
);
inserted += exerciseResult.insertedCount;
updated += exerciseResult.updatedCount;
ignored += exerciseResult.ignoredOlderCount;
final programResult = await _applyBackupResources(
resources: snapshot.programs,
resourceType: SyncResourceType.program,
tableName: 'programs',
importedAt: importedAt,
force: force,
);
inserted += programResult.insertedCount;
updated += programResult.updatedCount;
ignored += programResult.ignoredOlderCount;
final templateResult = await _applyBackupResources(
resources: snapshot.workoutTemplates,
resourceType: SyncResourceType.workoutTemplate,
tableName: 'workout_templates',
importedAt: importedAt,
force: force,
);
inserted += templateResult.insertedCount;
updated += templateResult.updatedCount;
ignored += templateResult.ignoredOlderCount;
final historyResult = await _applyBackupResources(
resources: snapshot.workoutHistories,
resourceType: SyncResourceType.workoutHistory,
tableName: 'workout_history',
importedAt: importedAt,
force: force,
);
inserted += historyResult.insertedCount;
updated += historyResult.updatedCount;
ignored += historyResult.ignoredOlderCount;
});
return LocalBackupImportResult(
insertedCount: inserted,
updatedCount: updated,
ignoredOlderCount: ignored,
deletedByReplaceCount: deleted,
missingMediaCount: 0,
);
}
Future<LocalBackupImportResult> _applyBackupResources({
required List<LocalBackupResource> resources,
required SyncResourceType resourceType,
required String tableName,
required DateTime importedAt,
required bool force,
}) async {
var inserted = 0;
var updated = 0;
var ignored = 0;
for (final resource in resources) {
final local = await _localSyncRow(tableName, resource.id);
if (!force &&
local != null &&
!resource.updatedAt.isAfter(local.updatedAt)) {
ignored += 1;
continue;
}
final revision = (local?.localRevision ?? 0) + 1;
final payload = _payloadWithImportedMetadata(
resource.payload,
importedAt,
revision,
snapshotOriginDeviceId: resource.payload['originDeviceId'] as String?,
);
await _saveBackupResource(
resourceType: resourceType,
resource: resource.copyWith(payload: payload),
importedAt: importedAt,
);
if (local == null || local.deletedAt != null) {
inserted += 1;
} else {
updated += 1;
}
}
return LocalBackupImportResult(
insertedCount: inserted,
updatedCount: updated,
ignoredOlderCount: ignored,
deletedByReplaceCount: 0,
missingMediaCount: 0,
);
}
Future<void> _saveBackupResource({
required SyncResourceType resourceType,
required LocalBackupResource resource,
required DateTime importedAt,
}) async {
final remoteItem = _remoteItemFromBackupResource(resourceType, resource);
switch (resourceType) {
case SyncResourceType.mediaAsset:
await DriftMediaAssetRepository(
database,
).save(_mediaAssetFromPayload(remoteItem));
case SyncResourceType.exercise:
await DriftExerciseRepository(
database,
).save(_exerciseFromPayload(remoteItem));
case SyncResourceType.program:
await DriftProgramRepository(
database,
).replaceExercises(_programFromPayload(remoteItem), importedAt);
case SyncResourceType.workoutTemplate:
await DriftWorkoutTemplateRepository(database).replaceComposition(
_workoutTemplateFromPayload(remoteItem),
importedAt,
);
case SyncResourceType.workoutHistory:
await _replaceWorkoutHistory(
_workoutHistoryFromLocalBackupPayload(remoteItem),
importedAt,
);
}
}
Future<void> _replaceWorkoutHistory(
domain.WorkoutHistory history,
DateTime deletedAt,
) async {
await DriftWorkoutHistoryRepository(database).save(history);
final activeResultIds = history.results
.map((result) => result.metadata.id)
.toSet();
final resultRows =
await (database.select(database.workoutHistorySetResults)..where(
(table) =>
table.workoutHistoryId.equals(history.metadata.id) &
table.deletedAt.isNull() &
(activeResultIds.isEmpty
? const Constant<bool>(true)
: table.id.isNotIn(activeResultIds)),
))
.get();
await _softDeleteWorkoutHistorySetResultRows(
database,
resultRows,
deletedAt,
);
final activeStepResultIds = history.stepResults
.map((result) => result.metadata.id)
.toSet();
final stepRows =
await (database.select(database.workoutHistoryStepResults)..where(
(table) =>
table.workoutHistoryId.equals(history.metadata.id) &
table.deletedAt.isNull() &
(activeStepResultIds.isEmpty
? const Constant<bool>(true)
: table.id.isNotIn(activeStepResultIds)),
))
.get();
await _softDeleteWorkoutHistoryStepResultRows(
database,
stepRows,
deletedAt,
);
}
Future<int> _softDeleteResourcesAbsentFromBackup(
LocalDataExportSnapshot snapshot,
DateTime deletedAt,
) async {
var deleted = 0;
deleted += await _softDeleteMainRowsAbsent(
tableName: 'media_assets',
entityType: 'MediaAsset',
keepIds: snapshot.mediaAssets.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'exercises',
entityType: 'Exercise',
keepIds: snapshot.exercises.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'programs',
entityType: 'Program',
keepIds: snapshot.programs.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'workout_templates',
entityType: 'WorkoutTemplate',
keepIds: snapshot.workoutTemplates.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'workout_history',
entityType: 'WorkoutHistory',
keepIds: snapshot.workoutHistories.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
await _softDeleteAllActiveSessionRows(deletedAt);
return deleted;
}
Future<int> _softDeleteMainRowsAbsent({
required String tableName,
required String entityType,
required Set<String> keepIds,
required DateTime deletedAt,
}) async {
final where = keepIds.isEmpty
? 'deleted_at IS NULL'
: 'deleted_at IS NULL AND id NOT IN (${List.filled(keepIds.length, '?').join(', ')})';
final rows = await database
.customSelect(
'SELECT id, local_revision, origin_device_id FROM $tableName WHERE $where',
variables: [for (final id in keepIds) Variable<String>(id)],
)
.get();
for (final row in rows) {
final id = row.read<String>('id');
final revision = row.read<int>('local_revision') + 1;
await database.customUpdate(
'UPDATE $tableName SET deleted_at = ?, updated_at = ?, '
'local_revision = ?, sync_state = ? WHERE id = ?',
variables: [
Variable<DateTime>(deletedAt.toUtc()),
Variable<DateTime>(deletedAt.toUtc()),
Variable<int>(revision),
const Variable<String>('deleted'),
Variable<String>(id),
],
);
await _writeChangeLog(
database: database,
entityType: entityType,
entityId: id,
operation: 'softDelete',
localRevision: revision,
originDeviceId: row.read<String>('origin_device_id'),
createdAt: deletedAt,
);
}
return rows.length;
}
Future<void> _softDeleteAllActiveSessionRows(DateTime deletedAt) async {
await database.customUpdate(
'UPDATE active_workout_sessions SET deleted_at = ?, updated_at = ? '
'WHERE deleted_at IS NULL',
variables: [
Variable<DateTime>(deletedAt.toUtc()),
Variable<DateTime>(deletedAt.toUtc()),
],
updates: {database.activeWorkoutSessions},
);
}
Future<_BackupLocalRow?> _localSyncRow(String tableName, String id) async {
final row = await database
.customSelect(
'SELECT updated_at, deleted_at, local_revision FROM $tableName '
'WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
if (row == null) {
return null;
}
return _BackupLocalRow(
updatedAt: _dateTimeFromData(row.data, 'updated_at'),
deletedAt: _dateTimeOrNullFromData(row.data, 'deleted_at'),
localRevision: row.read<int>('local_revision'),
);
}
}
final class DriftExercisePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository {
const DriftExercisePerformanceReferenceRepository(this.database);
@ -4051,6 +4435,18 @@ final class _LocalSyncSnapshot {
final Map<String, Object?> payload;
}
final class _BackupLocalRow {
const _BackupLocalRow({
required this.updatedAt,
required this.deletedAt,
required this.localRevision,
});
final DateTime updatedAt;
final DateTime? deletedAt;
final int localRevision;
}
Map<String, Object?> _metadataPayload(domain.EntityMetadata metadata) => {
'id': metadata.id,
'createdAt': metadata.createdAt.toUtc().toIso8601String(),
@ -4166,6 +4562,166 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'historySnapshotJson': history.historySnapshotJson,
};
Map<String, Object?> _localWorkoutHistoryPayload(
domain.WorkoutHistory history,
) => {
..._workoutHistoryPayload(history),
'results': history.results.map(_workoutHistorySetResultPayload).toList(),
'stepResults': history.stepResults
.map(_workoutHistoryStepResultPayload)
.toList(),
};
Map<String, Object?> _workoutHistorySetResultPayload(
domain.WorkoutHistorySetResult result,
) => {
'metadata': _metadataPayload(result.metadata),
'id': result.metadata.id,
'workoutHistoryId': result.workoutHistoryId,
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'programNameSnapshot': result.programNameSnapshot,
'exerciseNameSnapshot': result.exerciseNameSnapshot,
'timeEnabledSnapshot': result.timeEnabledSnapshot,
'repsEnabledSnapshot': result.repsEnabledSnapshot,
'scoreEnabledSnapshot': result.scoreEnabledSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
'targetTimeSecondsSnapshot': result.targetTimeSecondsSnapshot,
'targetRepsSnapshot': result.targetRepsSnapshot,
'targetScoreSnapshot': result.targetScoreSnapshot,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot,
'startedAt': result.startedAt?.toUtc().toIso8601String(),
'completedAt': result.completedAt?.toUtc().toIso8601String(),
'status': result.status.name,
};
Map<String, Object?> _workoutHistoryStepResultPayload(
domain.WorkoutHistoryStepResult result,
) => {
'metadata': _metadataPayload(result.metadata),
'id': result.metadata.id,
'workoutHistoryId': result.workoutHistoryId,
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'passageIndex': result.passageIndex,
'stepIndex': result.stepIndex,
'stepSnapshotId': result.stepSnapshotId,
'stepNameSnapshot': result.stepNameSnapshot,
'stepTypeSnapshot': result.stepTypeSnapshot.name,
'targetValueSnapshot': result.targetValueSnapshot,
'hasScoreSnapshot': result.hasScoreSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'targetScoreSnapshot': result.targetScoreSnapshot,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
'status': result.status.name,
'startedAt': result.startedAt?.toUtc().toIso8601String(),
'completedAt': result.completedAt?.toUtc().toIso8601String(),
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'note': result.note,
'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot,
};
LocalBackupResource _backupResource(
domain.EntityMetadata metadata,
Map<String, Object?> payload,
) {
return LocalBackupResource(
id: metadata.id,
updatedAt: metadata.updatedAt.toUtc(),
payload: payload,
);
}
RemoteSyncedItem _remoteItemFromBackupResource(
SyncResourceType resourceType,
LocalBackupResource resource,
) {
final metadata = resource.payload['metadata'];
final metadataMap = metadata is Map
? Map<String, Object?>.from(metadata)
: const <String, Object?>{};
return RemoteSyncedItem(
resourceType: resourceType,
clientId: resource.id,
serverId: '',
schemaVersion: metadataMap['schemaVersion'] as int? ?? 1,
clientUpdatedAt: resource.updatedAt.toUtc(),
serverUpdatedAt: resource.updatedAt.toUtc(),
deletedAt: _dateTimeFromPayload(metadataMap['deletedAt']),
payload: resource.payload,
);
}
Map<String, Object?> _payloadWithImportedMetadata(
Map<String, Object?> payload,
DateTime importedAt,
int localRevision, {
String? snapshotOriginDeviceId,
}) {
final metadata = payload['metadata'];
final metadataMap = metadata is Map
? Map<String, Object?>.from(metadata)
: <String, Object?>{};
return {
...payload,
'metadata': {
...metadataMap,
'updatedAt': importedAt.toUtc().toIso8601String(),
'deletedAt': null,
'syncState': domain.SyncState.dirty.name,
'localRevision': localRevision,
'originDeviceId':
metadataMap['originDeviceId'] as String? ??
snapshotOriginDeviceId ??
'local-device',
},
};
}
domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
RemoteSyncedItem item,
) {
final payload = item.payload;
final metadata = _metadataFromPayload(item);
return domain.WorkoutHistory(
metadata: metadata,
sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?,
sourceActiveWorkoutSessionId:
payload['sourceActiveWorkoutSessionId'] as String?,
nameSnapshot: _stringFromPayload(payload, 'nameSnapshot', item.clientId),
startedAt:
_dateTimeFromPayload(payload['startedAt']) ?? item.clientUpdatedAt,
endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt,
totalActiveMs: payload['totalActiveMs'] as int? ?? 0,
completed: payload['completed'] as bool? ?? false,
historySnapshotJson:
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'],
metadata,
),
);
}
domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
return domain.Exercise(
@ -4257,7 +4813,9 @@ domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) {
updatedAt: item.clientUpdatedAt,
deletedAt: item.deletedAt,
schemaVersion: item.schemaVersion,
syncState: item.deletedAt == null
syncState: map?['syncState'] is String
? _syncStateFromDb(map!['syncState'] as String)
: item.deletedAt == null
? domain.SyncState.synced
: domain.SyncState.deleted,
localRevision: map?['localRevision'] as int? ?? 0,
@ -4280,7 +4838,7 @@ List<domain.ProgramExercise> _programExercisesFromPayload(
return domain.ProgramExercise(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
programId: parentMetadata.id,
sourceExerciseId: null,
sourceExerciseId: map['sourceExerciseId'] as String?,
position: map['position'] as int? ?? 0,
exerciseNameSnapshot: _stringFromPayload(
map,
@ -4342,7 +4900,7 @@ List<domain.WorkoutTemplateProgram> _workoutTemplateProgramsFromPayload(
return domain.WorkoutTemplateProgram(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
workoutTemplateId: parentMetadata.id,
sourceProgramId: null,
sourceProgramId: map['sourceProgramId'] as String?,
position: map['position'] as int? ?? 0,
programNameSnapshot: _stringFromPayload(
map,
@ -4395,6 +4953,115 @@ _workoutTemplateOverridesFromPayload(
.toList(growable: false);
}
List<domain.WorkoutHistorySetResult> _workoutHistorySetResultsFromPayload(
Object? value,
domain.EntityMetadata parentMetadata,
) {
if (value is! List) {
return const [];
}
return value
.whereType<Map>()
.map((entry) {
final map = Map<String, Object?>.from(entry);
final id = _stringFromPayload(map, 'id', 'history-set-result');
return domain.WorkoutHistorySetResult(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
workoutHistoryId: parentMetadata.id,
programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''),
exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''),
programIndex: map['programIndex'] as int? ?? 0,
exerciseIndex: map['exerciseIndex'] as int? ?? 0,
setIndex: map['setIndex'] as int? ?? 0,
programNameSnapshot: _stringFromPayload(
map,
'programNameSnapshot',
'',
),
exerciseNameSnapshot: _stringFromPayload(
map,
'exerciseNameSnapshot',
'',
),
timeEnabledSnapshot: map['timeEnabledSnapshot'] == true,
repsEnabledSnapshot: map['repsEnabledSnapshot'] == true,
scoreEnabledSnapshot: map['scoreEnabledSnapshot'] == true,
scoreInputModeSnapshot: _scoreInputModeFromDb(
map['scoreInputModeSnapshot'] as String? ?? 'manual',
),
targetTimeSecondsSnapshot: map['targetTimeSecondsSnapshot'] as int?,
targetRepsSnapshot: map['targetRepsSnapshot'] as int?,
targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(),
targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?,
actualTimeMs: map['actualTimeMs'] as int?,
actualReps: map['actualReps'] as int?,
actualScore: (map['actualScore'] as num?)?.toDouble(),
actualScoreTimeMs: map['actualScoreTimeMs'] as int?,
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?,
startedAt: _dateTimeFromPayload(map['startedAt']),
completedAt: _dateTimeFromPayload(map['completedAt']),
status: _setResultStatusFromDb(
map['status'] as String? ?? 'completed',
),
);
})
.toList(growable: false);
}
List<domain.WorkoutHistoryStepResult> _workoutHistoryStepResultsFromPayload(
Object? value,
domain.EntityMetadata parentMetadata,
) {
if (value is! List) {
return const [];
}
return value
.whereType<Map>()
.map((entry) {
final map = Map<String, Object?>.from(entry);
final id = _stringFromPayload(map, 'id', 'history-step-result');
return domain.WorkoutHistoryStepResult(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
workoutHistoryId: parentMetadata.id,
programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''),
exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''),
programIndex: map['programIndex'] as int? ?? 0,
exerciseIndex: map['exerciseIndex'] as int? ?? 0,
setIndex: map['setIndex'] as int? ?? 0,
passageIndex: map['passageIndex'] as int? ?? 0,
stepIndex: map['stepIndex'] as int? ?? 0,
stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''),
stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''),
stepTypeSnapshot: _exerciseStepTypeFromDb(
map['stepTypeSnapshot'] as String? ?? 'work',
),
targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0,
hasScoreSnapshot: map['hasScoreSnapshot'] == true,
scoreInputModeSnapshot: map['scoreInputModeSnapshot'] == null
? null
: _scoreInputModeFromDb(map['scoreInputModeSnapshot'] as String),
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(),
targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?,
status: _setResultStatusFromDb(
map['status'] as String? ?? 'completed',
),
startedAt: _dateTimeFromPayload(map['startedAt']),
completedAt: _dateTimeFromPayload(map['completedAt']),
actualTimeMs: map['actualTimeMs'] as int?,
actualReps: map['actualReps'] as int?,
actualScore: (map['actualScore'] as num?)?.toDouble(),
actualScoreTimeMs: map['actualScoreTimeMs'] as int?,
note: map['note'] as String?,
sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?,
);
})
.toList(growable: false);
}
domain.EntityMetadata _childMetadataFromPayload(
Map<String, Object?> payload,
String id,

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
@ -7,7 +8,8 @@ import 'package:path_provider/path_provider.dart';
import '../../application/application.dart';
import '../../domain/domain.dart';
final class PathProviderLocalMediaStorage implements LocalMediaStorage {
final class PathProviderLocalMediaStorage
implements LocalMediaStorage, LocalBackupMediaStore {
const PathProviderLocalMediaStorage();
static const _mediaDirectoryName = 'gametime_media';
@ -77,6 +79,64 @@ final class PathProviderLocalMediaStorage implements LocalMediaStorage {
}
}
@override
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
List<LocalBackupResource> mediaAssets,
) async {
final files = <EmbeddedBackupMediaFile>[];
for (final resource in mediaAssets) {
final localUri = resource.payload['localUri'];
if (localUri is! String || localUri.isEmpty) {
continue;
}
final file = _fileFromLocalUri(localUri);
if (!await file.exists()) {
continue;
}
final bytes = await file.readAsBytes();
files.add(
EmbeddedBackupMediaFile(
mediaAssetId: resource.id,
role: 'original',
fileName: p.basename(file.path),
mimeType: resource.payload['mimeType'] as String?,
sizeBytes: bytes.length,
base64: base64Encode(bytes),
),
);
}
return files;
}
@override
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
List<EmbeddedBackupMediaFile> files,
) async {
final restored = <String, RestoredMediaFile>{};
final directory = await _restoredBackupDirectory();
await directory.create(recursive: true);
for (final file in files) {
final bytes = switch (_decodeBase64OrNull(file.base64)) {
final decoded? => decoded,
null => null,
};
if (bytes == null) {
continue;
}
final extension = p.extension(file.fileName).toLowerCase();
final target = File(
p.join(directory.path, '${file.mediaAssetId}$extension'),
);
await target.writeAsBytes(bytes, flush: true);
restored[file.mediaAssetId] = RestoredMediaFile(
mediaAssetId: file.mediaAssetId,
localUri: target.uri.toString(),
sizeBytes: bytes.length,
);
}
return restored;
}
Future<Directory> _kindDirectory(MediaKind kind) async {
final root = await _rootDirectory();
final child = switch (kind) {
@ -90,6 +150,27 @@ final class PathProviderLocalMediaStorage implements LocalMediaStorage {
final documents = await getApplicationDocumentsDirectory();
return Directory(p.join(documents.path, _mediaDirectoryName));
}
Future<Directory> _restoredBackupDirectory() async {
final root = await _rootDirectory();
return Directory(p.join(root.path, 'restored'));
}
}
File _fileFromLocalUri(String localUri) {
final uri = Uri.tryParse(localUri);
if (uri != null && uri.scheme == 'file') {
return File.fromUri(uri);
}
return File(localUri);
}
Uint8List? _decodeBase64OrNull(String value) {
try {
return base64Decode(value);
} on FormatException {
return null;
}
}
final class _ImageDimensions {