feat: add local backup export import core
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user