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

@ -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,

View File

@ -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 }

View File

@ -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';
}