feat: add local backup export import core
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -15,6 +15,8 @@ abstract interface class AppDependencies {
|
||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||
ProgressionStatsUseCase get progressionStatsUseCase;
|
||||
ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase;
|
||||
DataExportUseCase get dataExportUseCase;
|
||||
DataImportUseCase get dataImportUseCase;
|
||||
SyncUseCases get syncUseCases;
|
||||
ShareUseCases get shareUseCases;
|
||||
}
|
||||
@ -33,6 +35,8 @@ final class AppBootstrap implements AppDependencies {
|
||||
required this.workoutHistoryUseCases,
|
||||
required this.progressionStatsUseCase,
|
||||
required this.exercisePerformanceReferenceUseCase,
|
||||
required this.dataExportUseCase,
|
||||
required this.dataImportUseCase,
|
||||
required this.syncUseCases,
|
||||
required this.shareUseCases,
|
||||
required this.syncGateway,
|
||||
@ -62,6 +66,10 @@ final class AppBootstrap implements AppDependencies {
|
||||
@override
|
||||
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
|
||||
@override
|
||||
final DataExportUseCase dataExportUseCase;
|
||||
@override
|
||||
final DataImportUseCase dataImportUseCase;
|
||||
@override
|
||||
final SyncUseCases syncUseCases;
|
||||
@override
|
||||
final ShareUseCases shareUseCases;
|
||||
@ -90,6 +98,8 @@ final class AppBootstrap implements AppDependencies {
|
||||
);
|
||||
final performanceReferenceRepository =
|
||||
DriftExercisePerformanceReferenceRepository(database);
|
||||
final localDataBackupRepository = DriftLocalDataBackupRepository(database);
|
||||
const localBackupMediaStore = PathProviderLocalMediaStorage();
|
||||
final ids = LocalIdGenerator();
|
||||
const clock = SystemClock();
|
||||
const originDeviceId = 'local-device';
|
||||
@ -179,6 +189,16 @@ final class AppBootstrap implements AppDependencies {
|
||||
exercisePerformanceReferenceUseCase: ExercisePerformanceReferenceUseCase(
|
||||
repository: performanceReferenceRepository,
|
||||
),
|
||||
dataExportUseCase: DataExportUseCase(
|
||||
repository: localDataBackupRepository,
|
||||
mediaStore: localBackupMediaStore,
|
||||
clock: clock,
|
||||
),
|
||||
dataImportUseCase: DataImportUseCase(
|
||||
repository: localDataBackupRepository,
|
||||
mediaStore: localBackupMediaStore,
|
||||
clock: clock,
|
||||
),
|
||||
syncUseCases: SyncUseCases(
|
||||
tokenStore: const SecureStorageAuthTokenStore(),
|
||||
remoteSyncApi: remoteSyncApi,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../domain/domain.dart';
|
||||
|
||||
abstract interface class Clock {
|
||||
@ -34,6 +36,213 @@ final class TagUsage {
|
||||
final int count;
|
||||
}
|
||||
|
||||
enum LocalBackupImportMode { merge, replaceAll }
|
||||
|
||||
enum LocalBackupValidationError {
|
||||
invalidFile,
|
||||
incompatibleFormat,
|
||||
newerVersion,
|
||||
corrupted,
|
||||
activeWorkoutInProgress,
|
||||
importFailedNoMutation,
|
||||
}
|
||||
|
||||
final class LocalBackupException implements Exception {
|
||||
const LocalBackupException(this.error);
|
||||
|
||||
final LocalBackupValidationError error;
|
||||
|
||||
@override
|
||||
String toString() => error.name;
|
||||
}
|
||||
|
||||
final class LocalBackupDocument {
|
||||
const LocalBackupDocument({required this.fileName, required this.bytes});
|
||||
|
||||
final String fileName;
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
final class LocalBackupCounts {
|
||||
const LocalBackupCounts({
|
||||
required this.exercises,
|
||||
required this.programs,
|
||||
required this.workoutTemplates,
|
||||
required this.workoutHistories,
|
||||
required this.mediaAssets,
|
||||
required this.embeddedMediaFiles,
|
||||
});
|
||||
|
||||
final int exercises;
|
||||
final int programs;
|
||||
final int workoutTemplates;
|
||||
final int workoutHistories;
|
||||
final int mediaAssets;
|
||||
final int embeddedMediaFiles;
|
||||
}
|
||||
|
||||
final class LocalBackupPreview {
|
||||
const LocalBackupPreview({
|
||||
required this.exportedAt,
|
||||
required this.counts,
|
||||
required this.hasEmbeddedMedia,
|
||||
required this.missingMediaCount,
|
||||
required this.hasNameDuplicates,
|
||||
});
|
||||
|
||||
final DateTime exportedAt;
|
||||
final LocalBackupCounts counts;
|
||||
final bool hasEmbeddedMedia;
|
||||
final int missingMediaCount;
|
||||
final bool hasNameDuplicates;
|
||||
}
|
||||
|
||||
final class LocalBackupImportResult {
|
||||
const LocalBackupImportResult({
|
||||
required this.insertedCount,
|
||||
required this.updatedCount,
|
||||
required this.ignoredOlderCount,
|
||||
required this.deletedByReplaceCount,
|
||||
required this.missingMediaCount,
|
||||
});
|
||||
|
||||
final int insertedCount;
|
||||
final int updatedCount;
|
||||
final int ignoredOlderCount;
|
||||
final int deletedByReplaceCount;
|
||||
final int missingMediaCount;
|
||||
|
||||
LocalBackupImportResult copyWith({int? missingMediaCount}) {
|
||||
return LocalBackupImportResult(
|
||||
insertedCount: insertedCount,
|
||||
updatedCount: updatedCount,
|
||||
ignoredOlderCount: ignoredOlderCount,
|
||||
deletedByReplaceCount: deletedByReplaceCount,
|
||||
missingMediaCount: missingMediaCount ?? this.missingMediaCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class LocalBackupResource {
|
||||
const LocalBackupResource({
|
||||
required this.id,
|
||||
required this.updatedAt,
|
||||
required this.payload,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final DateTime updatedAt;
|
||||
final Map<String, Object?> payload;
|
||||
|
||||
LocalBackupResource copyWith({Map<String, Object?>? payload}) {
|
||||
return LocalBackupResource(
|
||||
id: id,
|
||||
updatedAt: updatedAt,
|
||||
payload: payload ?? this.payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class LocalDataExportSnapshot {
|
||||
const LocalDataExportSnapshot({
|
||||
required this.exportedAt,
|
||||
required this.appSchemaVersion,
|
||||
required this.originDeviceId,
|
||||
required this.mediaAssets,
|
||||
required this.exercises,
|
||||
required this.programs,
|
||||
required this.workoutTemplates,
|
||||
required this.workoutHistories,
|
||||
this.mediaFiles = const [],
|
||||
});
|
||||
|
||||
final DateTime exportedAt;
|
||||
final int appSchemaVersion;
|
||||
final String originDeviceId;
|
||||
final List<LocalBackupResource> mediaAssets;
|
||||
final List<LocalBackupResource> exercises;
|
||||
final List<LocalBackupResource> programs;
|
||||
final List<LocalBackupResource> workoutTemplates;
|
||||
final List<LocalBackupResource> workoutHistories;
|
||||
final List<EmbeddedBackupMediaFile> mediaFiles;
|
||||
|
||||
LocalBackupCounts get counts => LocalBackupCounts(
|
||||
exercises: exercises.length,
|
||||
programs: programs.length,
|
||||
workoutTemplates: workoutTemplates.length,
|
||||
workoutHistories: workoutHistories.length,
|
||||
mediaAssets: mediaAssets.length,
|
||||
embeddedMediaFiles: mediaFiles.length,
|
||||
);
|
||||
|
||||
LocalDataExportSnapshot copyWith({
|
||||
List<LocalBackupResource>? mediaAssets,
|
||||
List<EmbeddedBackupMediaFile>? mediaFiles,
|
||||
}) {
|
||||
return LocalDataExportSnapshot(
|
||||
exportedAt: exportedAt,
|
||||
appSchemaVersion: appSchemaVersion,
|
||||
originDeviceId: originDeviceId,
|
||||
mediaAssets: mediaAssets ?? this.mediaAssets,
|
||||
exercises: exercises,
|
||||
programs: programs,
|
||||
workoutTemplates: workoutTemplates,
|
||||
workoutHistories: workoutHistories,
|
||||
mediaFiles: mediaFiles ?? this.mediaFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class EmbeddedBackupMediaFile {
|
||||
const EmbeddedBackupMediaFile({
|
||||
required this.mediaAssetId,
|
||||
required this.role,
|
||||
required this.fileName,
|
||||
required this.mimeType,
|
||||
required this.sizeBytes,
|
||||
required this.base64,
|
||||
});
|
||||
|
||||
final String mediaAssetId;
|
||||
final String role;
|
||||
final String fileName;
|
||||
final String? mimeType;
|
||||
final int sizeBytes;
|
||||
final String base64;
|
||||
}
|
||||
|
||||
final class RestoredMediaFile {
|
||||
const RestoredMediaFile({
|
||||
required this.mediaAssetId,
|
||||
required this.localUri,
|
||||
required this.sizeBytes,
|
||||
});
|
||||
|
||||
final String mediaAssetId;
|
||||
final String localUri;
|
||||
final int sizeBytes;
|
||||
}
|
||||
|
||||
abstract interface class LocalDataBackupRepository {
|
||||
Future<LocalDataExportSnapshot> readExportSnapshot(DateTime exportedAt);
|
||||
Future<bool> hasOpenActiveWorkoutSession();
|
||||
Future<bool> hasAnyUserData();
|
||||
Future<LocalBackupImportResult> applyImportSnapshot({
|
||||
required LocalDataExportSnapshot snapshot,
|
||||
required LocalBackupImportMode mode,
|
||||
required DateTime importedAt,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class LocalBackupMediaStore {
|
||||
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
|
||||
List<LocalBackupResource> mediaAssets,
|
||||
);
|
||||
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
|
||||
List<EmbeddedBackupMediaFile> files,
|
||||
);
|
||||
}
|
||||
|
||||
enum ProgressionPeriod { fourWeeks, threeMonths, all }
|
||||
|
||||
enum ProgressionMeasure { manualScore, stopwatchScore, reps, time }
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../domain/domain.dart';
|
||||
import 'ports.dart';
|
||||
@ -49,6 +50,175 @@ List<TagUsage> tagSuggestionsFor<T>(
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
final class LocalBackupCodec {
|
||||
const LocalBackupCodec();
|
||||
|
||||
static const kind = 'gametime.localBackup';
|
||||
static const formatVersion = 1;
|
||||
static const minSupportedFormatVersion = 1;
|
||||
|
||||
Uint8List encode(LocalDataExportSnapshot snapshot) {
|
||||
final document = <String, Object?>{
|
||||
'kind': kind,
|
||||
'formatVersion': formatVersion,
|
||||
'minSupportedFormatVersion': minSupportedFormatVersion,
|
||||
'exportedAt': snapshot.exportedAt.toUtc().toIso8601String(),
|
||||
'appSchemaVersion': snapshot.appSchemaVersion,
|
||||
'originDeviceId': snapshot.originDeviceId,
|
||||
'counts': _countsToJson(snapshot.counts),
|
||||
'data': {
|
||||
'mediaAssets': snapshot.mediaAssets
|
||||
.map((resource) => resource.payload)
|
||||
.toList(),
|
||||
'exercises': snapshot.exercises
|
||||
.map((resource) => resource.payload)
|
||||
.toList(),
|
||||
'programs': snapshot.programs
|
||||
.map((resource) => resource.payload)
|
||||
.toList(),
|
||||
'workoutTemplates': snapshot.workoutTemplates
|
||||
.map((resource) => resource.payload)
|
||||
.toList(),
|
||||
'workoutHistories': snapshot.workoutHistories
|
||||
.map((resource) => resource.payload)
|
||||
.toList(),
|
||||
},
|
||||
'mediaFiles': snapshot.mediaFiles.map(_mediaFileToJson).toList(),
|
||||
};
|
||||
return Uint8List.fromList(utf8.encode(jsonEncode(document)));
|
||||
}
|
||||
|
||||
LocalDataExportSnapshot decode(Uint8List bytes) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(utf8.decode(bytes));
|
||||
} on FormatException {
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
} on Object {
|
||||
throw const LocalBackupException(LocalBackupValidationError.invalidFile);
|
||||
}
|
||||
if (decoded is! Map) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.invalidFile);
|
||||
}
|
||||
final root = Map<String, Object?>.from(decoded);
|
||||
if (root['kind'] != kind) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.invalidFile);
|
||||
}
|
||||
final version = root['formatVersion'];
|
||||
final minSupported = root['minSupportedFormatVersion'];
|
||||
if (version is! int || minSupported is! int || version < 1) {
|
||||
throw const LocalBackupException(
|
||||
LocalBackupValidationError.incompatibleFormat,
|
||||
);
|
||||
}
|
||||
if (version > formatVersion || minSupported > formatVersion) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.newerVersion);
|
||||
}
|
||||
final data = root['data'];
|
||||
if (data is! Map) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
}
|
||||
final dataMap = Map<String, Object?>.from(data);
|
||||
return LocalDataExportSnapshot(
|
||||
exportedAt: _dateTimeFromBackup(root['exportedAt']),
|
||||
appSchemaVersion: root['appSchemaVersion'] as int? ?? 1,
|
||||
originDeviceId: root['originDeviceId'] as String? ?? 'local-device',
|
||||
mediaAssets: _resourcesFromBackup(dataMap, 'mediaAssets'),
|
||||
exercises: _resourcesFromBackup(dataMap, 'exercises'),
|
||||
programs: _resourcesFromBackup(dataMap, 'programs'),
|
||||
workoutTemplates: _resourcesFromBackup(dataMap, 'workoutTemplates'),
|
||||
workoutHistories: _resourcesFromBackup(dataMap, 'workoutHistories'),
|
||||
mediaFiles: _mediaFilesFromBackup(root['mediaFiles']),
|
||||
);
|
||||
}
|
||||
|
||||
LocalBackupPreview preview(Uint8List bytes) {
|
||||
final snapshot = decode(bytes);
|
||||
return LocalBackupPreview(
|
||||
exportedAt: snapshot.exportedAt,
|
||||
counts: snapshot.counts,
|
||||
hasEmbeddedMedia: snapshot.mediaFiles.isNotEmpty,
|
||||
missingMediaCount: _missingMediaCount(snapshot),
|
||||
hasNameDuplicates: _hasNameDuplicates(snapshot),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class DataExportUseCase {
|
||||
const DataExportUseCase({
|
||||
required this.repository,
|
||||
required this.mediaStore,
|
||||
required this.clock,
|
||||
this.codec = const LocalBackupCodec(),
|
||||
});
|
||||
|
||||
final LocalDataBackupRepository repository;
|
||||
final LocalBackupMediaStore mediaStore;
|
||||
final Clock clock;
|
||||
final LocalBackupCodec codec;
|
||||
|
||||
Future<LocalBackupDocument> exportAll() async {
|
||||
final exportedAt = clock.now();
|
||||
final snapshot = await repository.readExportSnapshot(exportedAt);
|
||||
final mediaFiles = await mediaStore.readEmbeddableFiles(
|
||||
snapshot.mediaAssets,
|
||||
);
|
||||
final completeSnapshot = snapshot.copyWith(mediaFiles: mediaFiles);
|
||||
return LocalBackupDocument(
|
||||
fileName: _localBackupFileName(exportedAt),
|
||||
bytes: codec.encode(completeSnapshot),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class DataImportUseCase {
|
||||
const DataImportUseCase({
|
||||
required this.repository,
|
||||
required this.mediaStore,
|
||||
required this.clock,
|
||||
this.codec = const LocalBackupCodec(),
|
||||
});
|
||||
|
||||
final LocalDataBackupRepository repository;
|
||||
final LocalBackupMediaStore mediaStore;
|
||||
final Clock clock;
|
||||
final LocalBackupCodec codec;
|
||||
|
||||
Future<LocalBackupPreview> preview(Uint8List bytes) async {
|
||||
return codec.preview(bytes);
|
||||
}
|
||||
|
||||
Future<LocalBackupImportResult> importFrom(
|
||||
Uint8List bytes, {
|
||||
required LocalBackupImportMode mode,
|
||||
}) async {
|
||||
if (await repository.hasOpenActiveWorkoutSession()) {
|
||||
throw const LocalBackupException(
|
||||
LocalBackupValidationError.activeWorkoutInProgress,
|
||||
);
|
||||
}
|
||||
final decoded = codec.decode(bytes);
|
||||
final restoredMediaFiles = await mediaStore.restoreEmbeddedFiles(
|
||||
decoded.mediaFiles,
|
||||
);
|
||||
final snapshot = _withRestoredMediaUris(decoded, restoredMediaFiles);
|
||||
try {
|
||||
final result = await repository.applyImportSnapshot(
|
||||
snapshot: snapshot,
|
||||
mode: mode,
|
||||
importedAt: clock.now(),
|
||||
);
|
||||
return result.copyWith(missingMediaCount: _missingMediaCount(snapshot));
|
||||
} on LocalBackupException {
|
||||
rethrow;
|
||||
} on Object {
|
||||
throw const LocalBackupException(
|
||||
LocalBackupValidationError.importFailedNoMutation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty }
|
||||
|
||||
final class StarterSeedResult {
|
||||
@ -4714,3 +4884,160 @@ String _copyName(String sourceName, Iterable<String> existingNames) {
|
||||
copyNumber += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _countsToJson(LocalBackupCounts counts) => {
|
||||
'exercises': counts.exercises,
|
||||
'programs': counts.programs,
|
||||
'workoutTemplates': counts.workoutTemplates,
|
||||
'workoutHistories': counts.workoutHistories,
|
||||
'mediaAssets': counts.mediaAssets,
|
||||
'embeddedMediaFiles': counts.embeddedMediaFiles,
|
||||
};
|
||||
|
||||
Map<String, Object?> _mediaFileToJson(EmbeddedBackupMediaFile file) => {
|
||||
'mediaAssetId': file.mediaAssetId,
|
||||
'role': file.role,
|
||||
'fileName': file.fileName,
|
||||
'mimeType': file.mimeType,
|
||||
'sizeBytes': file.sizeBytes,
|
||||
'base64': file.base64,
|
||||
};
|
||||
|
||||
List<LocalBackupResource> _resourcesFromBackup(
|
||||
Map<String, Object?> data,
|
||||
String key,
|
||||
) {
|
||||
final value = data[key];
|
||||
if (value is! List) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
}
|
||||
return value
|
||||
.map((item) {
|
||||
if (item is! Map) {
|
||||
throw const LocalBackupException(
|
||||
LocalBackupValidationError.corrupted,
|
||||
);
|
||||
}
|
||||
final payload = Map<String, Object?>.from(item);
|
||||
return LocalBackupResource(
|
||||
id: _stringFromBackup(payload, 'id'),
|
||||
updatedAt: _updatedAtFromBackupPayload(payload),
|
||||
payload: payload,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<EmbeddedBackupMediaFile> _mediaFilesFromBackup(Object? value) {
|
||||
if (value == null) {
|
||||
return const [];
|
||||
}
|
||||
if (value is! List) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
}
|
||||
return value
|
||||
.map((item) {
|
||||
if (item is! Map) {
|
||||
throw const LocalBackupException(
|
||||
LocalBackupValidationError.corrupted,
|
||||
);
|
||||
}
|
||||
final payload = Map<String, Object?>.from(item);
|
||||
return EmbeddedBackupMediaFile(
|
||||
mediaAssetId: _stringFromBackup(payload, 'mediaAssetId'),
|
||||
role: payload['role'] as String? ?? 'original',
|
||||
fileName: _stringFromBackup(payload, 'fileName'),
|
||||
mimeType: payload['mimeType'] as String?,
|
||||
sizeBytes: payload['sizeBytes'] as int? ?? 0,
|
||||
base64: _stringFromBackup(payload, 'base64'),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _stringFromBackup(Map<String, Object?> payload, String key) {
|
||||
final value = payload[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
}
|
||||
|
||||
DateTime _updatedAtFromBackupPayload(Map<String, Object?> payload) {
|
||||
final metadata = payload['metadata'];
|
||||
if (metadata is! Map) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
}
|
||||
return _dateTimeFromBackup(Map<String, Object?>.from(metadata)['updatedAt']);
|
||||
}
|
||||
|
||||
DateTime _dateTimeFromBackup(Object? value) {
|
||||
if (value is! String) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
}
|
||||
final parsed = DateTime.tryParse(value);
|
||||
if (parsed == null) {
|
||||
throw const LocalBackupException(LocalBackupValidationError.corrupted);
|
||||
}
|
||||
return parsed.toUtc();
|
||||
}
|
||||
|
||||
int _missingMediaCount(LocalDataExportSnapshot snapshot) {
|
||||
final embeddedIds = snapshot.mediaFiles
|
||||
.map((file) => file.mediaAssetId)
|
||||
.toSet();
|
||||
return snapshot.mediaAssets
|
||||
.where((asset) => !embeddedIds.contains(asset.id))
|
||||
.length;
|
||||
}
|
||||
|
||||
bool _hasNameDuplicates(LocalDataExportSnapshot snapshot) {
|
||||
return _hasDuplicatesByName(snapshot.exercises) ||
|
||||
_hasDuplicatesByName(snapshot.programs) ||
|
||||
_hasDuplicatesByName(snapshot.workoutTemplates);
|
||||
}
|
||||
|
||||
bool _hasDuplicatesByName(List<LocalBackupResource> resources) {
|
||||
final names = <String>{};
|
||||
for (final resource in resources) {
|
||||
final name = resource.payload['name'];
|
||||
if (name is! String) {
|
||||
continue;
|
||||
}
|
||||
if (!names.add(name.trim().toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalDataExportSnapshot _withRestoredMediaUris(
|
||||
LocalDataExportSnapshot snapshot,
|
||||
Map<String, RestoredMediaFile> restoredFiles,
|
||||
) {
|
||||
if (restoredFiles.isEmpty) {
|
||||
return snapshot;
|
||||
}
|
||||
final mediaAssets = [
|
||||
for (final asset in snapshot.mediaAssets)
|
||||
if (restoredFiles[asset.id] case final restored?)
|
||||
asset.copyWith(
|
||||
payload: {
|
||||
...asset.payload,
|
||||
'localUri': restored.localUri,
|
||||
'sizeBytes': restored.sizeBytes,
|
||||
},
|
||||
)
|
||||
else
|
||||
asset,
|
||||
];
|
||||
return snapshot.copyWith(mediaAssets: mediaAssets);
|
||||
}
|
||||
|
||||
String _localBackupFileName(DateTime exportedAt) {
|
||||
final utc = exportedAt.toUtc();
|
||||
final year = utc.year.toString().padLeft(4, '0');
|
||||
final month = utc.month.toString().padLeft(2, '0');
|
||||
final day = utc.day.toString().padLeft(2, '0');
|
||||
return 'gametime-sauvegarde-$year-$month-$day.gametime';
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -243,6 +243,16 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
),
|
||||
exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase(
|
||||
repository: _FakeExercisePerformanceReferenceRepository(),
|
||||
),
|
||||
dataExportUseCase = DataExportUseCase(
|
||||
repository: _FakeLocalDataBackupRepository(),
|
||||
mediaStore: _FakeLocalBackupMediaStore(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
),
|
||||
dataImportUseCase = DataImportUseCase(
|
||||
repository: _FakeLocalDataBackupRepository(),
|
||||
mediaStore: _FakeLocalBackupMediaStore(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
);
|
||||
|
||||
@override
|
||||
@ -283,6 +293,12 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
|
||||
@override
|
||||
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
|
||||
|
||||
@override
|
||||
final DataExportUseCase dataExportUseCase;
|
||||
|
||||
@override
|
||||
final DataImportUseCase dataImportUseCase;
|
||||
}
|
||||
|
||||
String _sessionSnapshot() {
|
||||
@ -682,6 +698,62 @@ final class _FakeMediaStorage implements LocalMediaStorage {
|
||||
Future<Set<String>> listManagedLocalUris() async => const {};
|
||||
}
|
||||
|
||||
final class _FakeLocalDataBackupRepository
|
||||
implements LocalDataBackupRepository {
|
||||
@override
|
||||
Future<LocalBackupImportResult> applyImportSnapshot({
|
||||
required LocalDataExportSnapshot snapshot,
|
||||
required LocalBackupImportMode mode,
|
||||
required DateTime importedAt,
|
||||
}) async {
|
||||
return const LocalBackupImportResult(
|
||||
insertedCount: 0,
|
||||
updatedCount: 0,
|
||||
ignoredOlderCount: 0,
|
||||
deletedByReplaceCount: 0,
|
||||
missingMediaCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasAnyUserData() async => false;
|
||||
|
||||
@override
|
||||
Future<bool> hasOpenActiveWorkoutSession() async => false;
|
||||
|
||||
@override
|
||||
Future<LocalDataExportSnapshot> readExportSnapshot(
|
||||
DateTime exportedAt,
|
||||
) async {
|
||||
return LocalDataExportSnapshot(
|
||||
exportedAt: exportedAt,
|
||||
appSchemaVersion: 1,
|
||||
originDeviceId: 'device-1',
|
||||
mediaAssets: const [],
|
||||
exercises: const [],
|
||||
programs: const [],
|
||||
workoutTemplates: const [],
|
||||
workoutHistories: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
|
||||
@override
|
||||
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
|
||||
List<LocalBackupResource> mediaAssets,
|
||||
) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
|
||||
List<EmbeddedBackupMediaFile> files,
|
||||
) async {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeProgramRepository implements ProgramRepository {
|
||||
@override
|
||||
Future<Program?> findById(String id) async => null;
|
||||
|
||||
Reference in New Issue
Block a user