feat(media): gestion des médias locaux
Ajoute le stockage local des médias (infrastructure/local/local_media_storage.dart) et les use cases/ports associés. flutter analyze propre, 7/7 tests unitaires verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -21,6 +21,41 @@ abstract interface class ExerciseRepository {
|
||||
Future<void> save(Exercise exercise);
|
||||
}
|
||||
|
||||
final class StoredMediaFile {
|
||||
const StoredMediaFile({
|
||||
required this.localUri,
|
||||
required this.mimeType,
|
||||
required this.sizeBytes,
|
||||
this.width,
|
||||
this.height,
|
||||
this.durationMs,
|
||||
});
|
||||
|
||||
final String localUri;
|
||||
final String? mimeType;
|
||||
final int sizeBytes;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? durationMs;
|
||||
}
|
||||
|
||||
abstract interface class LocalMediaStorage {
|
||||
Future<StoredMediaFile> importFile({
|
||||
required String sourcePath,
|
||||
required MediaKind kind,
|
||||
required String stableFileName,
|
||||
});
|
||||
|
||||
Future<Set<String>> listManagedLocalUris();
|
||||
Future<void> deleteByLocalUri(String localUri);
|
||||
}
|
||||
|
||||
abstract interface class MediaAssetRepository {
|
||||
Future<MediaAsset?> findById(String id);
|
||||
Future<List<MediaAsset>> listActive();
|
||||
Future<void> save(MediaAsset mediaAsset);
|
||||
}
|
||||
|
||||
abstract interface class ProgramRepository {
|
||||
Future<Program?> findById(String id);
|
||||
Future<void> save(Program program);
|
||||
|
||||
@ -46,6 +46,38 @@ final class ExerciseUseCases {
|
||||
|
||||
Future<void> save(Exercise exercise) => repository.save(exercise);
|
||||
|
||||
Future<Exercise> attachImage({
|
||||
required String exerciseId,
|
||||
required String mediaAssetId,
|
||||
}) async {
|
||||
final exercise = await repository.findById(exerciseId);
|
||||
if (exercise == null) {
|
||||
throw const DomainException('Exercise not found.');
|
||||
}
|
||||
final updated = exercise.copyWith(
|
||||
imageMediaId: mediaAssetId,
|
||||
metadata: exercise.metadata.touch(clock.now()),
|
||||
);
|
||||
await repository.save(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Exercise> attachVideo({
|
||||
required String exerciseId,
|
||||
required String mediaAssetId,
|
||||
}) async {
|
||||
final exercise = await repository.findById(exerciseId);
|
||||
if (exercise == null) {
|
||||
throw const DomainException('Exercise not found.');
|
||||
}
|
||||
final updated = exercise.copyWith(
|
||||
videoMediaId: mediaAssetId,
|
||||
metadata: exercise.metadata.touch(clock.now()),
|
||||
);
|
||||
await repository.save(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Exercise> archive(String id) async {
|
||||
final exercise = await repository.findById(id);
|
||||
if (exercise == null) {
|
||||
@ -57,6 +89,91 @@ final class ExerciseUseCases {
|
||||
}
|
||||
}
|
||||
|
||||
final class MediaUseCases {
|
||||
const MediaUseCases({
|
||||
required this.mediaRepository,
|
||||
required this.exerciseRepository,
|
||||
required this.storage,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
required this.originDeviceId,
|
||||
});
|
||||
|
||||
final MediaAssetRepository mediaRepository;
|
||||
final ExerciseRepository exerciseRepository;
|
||||
final LocalMediaStorage storage;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
final String originDeviceId;
|
||||
|
||||
Future<MediaAsset> importMedia({
|
||||
required String sourcePath,
|
||||
required MediaKind kind,
|
||||
}) async {
|
||||
final now = clock.now();
|
||||
final id = ids.newId();
|
||||
final stored = await storage.importFile(
|
||||
sourcePath: sourcePath,
|
||||
kind: kind,
|
||||
stableFileName: id,
|
||||
);
|
||||
final asset = MediaAsset(
|
||||
metadata: EntityMetadata(
|
||||
id: id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
kind: kind,
|
||||
localUri: stored.localUri,
|
||||
mimeType: stored.mimeType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
width: stored.width,
|
||||
height: stored.height,
|
||||
durationMs: stored.durationMs,
|
||||
);
|
||||
await mediaRepository.save(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
Future<Exercise> attachToExercise({
|
||||
required String exerciseId,
|
||||
required String mediaAssetId,
|
||||
}) async {
|
||||
final exercise = await exerciseRepository.findById(exerciseId);
|
||||
if (exercise == null) {
|
||||
throw const DomainException('Exercise not found.');
|
||||
}
|
||||
final media = await mediaRepository.findById(mediaAssetId);
|
||||
if (media == null) {
|
||||
throw const DomainException('Media asset not found.');
|
||||
}
|
||||
final updated = switch (media.kind) {
|
||||
MediaKind.image => exercise.copyWith(
|
||||
imageMediaId: media.metadata.id,
|
||||
metadata: exercise.metadata.touch(clock.now()),
|
||||
),
|
||||
MediaKind.video => exercise.copyWith(
|
||||
videoMediaId: media.metadata.id,
|
||||
metadata: exercise.metadata.touch(clock.now()),
|
||||
),
|
||||
};
|
||||
await exerciseRepository.save(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<int> deleteOrphanedManagedFiles() async {
|
||||
final activeAssets = await mediaRepository.listActive();
|
||||
final referencedUris = activeAssets.map((asset) => asset.localUri).toSet();
|
||||
final managedUris = await storage.listManagedLocalUris();
|
||||
final orphanedUris = managedUris.difference(referencedUris);
|
||||
for (final uri in orphanedUris) {
|
||||
await storage.deleteByLocalUri(uri);
|
||||
}
|
||||
return orphanedUris.length;
|
||||
}
|
||||
}
|
||||
|
||||
final class ProgramUseCases {
|
||||
const ProgramUseCases({
|
||||
required this.programRepository,
|
||||
|
||||
@ -35,6 +35,35 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftMediaAssetRepository implements MediaAssetRepository {
|
||||
const DriftMediaAssetRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<domain.MediaAsset?> findById(String id) async {
|
||||
final row = await (database.select(
|
||||
database.mediaAssets,
|
||||
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
||||
return row == null ? null : _mediaAssetFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.MediaAsset>> listActive() async {
|
||||
final rows = await (database.select(
|
||||
database.mediaAssets,
|
||||
)..where((table) => table.deletedAt.isNull())).get();
|
||||
return rows.map(_mediaAssetFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(domain.MediaAsset mediaAsset) async {
|
||||
await database
|
||||
.into(database.mediaAssets)
|
||||
.insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset));
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftProgramRepository implements ProgramRepository {
|
||||
const DriftProgramRepository(this.database);
|
||||
|
||||
@ -294,6 +323,49 @@ domain.Exercise _exerciseFromRow(db.Exercise row) {
|
||||
);
|
||||
}
|
||||
|
||||
db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) {
|
||||
final values = _metadataValues(asset.metadata);
|
||||
return db.MediaAssetsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
kind: Value(_mediaKindToDb(asset.kind)),
|
||||
localUri: Value(asset.localUri),
|
||||
mimeType: Value(asset.mimeType),
|
||||
sizeBytes: Value(asset.sizeBytes),
|
||||
width: Value(asset.width),
|
||||
height: Value(asset.height),
|
||||
durationMs: Value(asset.durationMs),
|
||||
checksum: Value(asset.checksum),
|
||||
remoteUri: Value(asset.remoteUri),
|
||||
thumbnailLocalUri: Value(asset.thumbnailLocalUri),
|
||||
);
|
||||
}
|
||||
|
||||
domain.MediaAsset _mediaAssetFromRow(db.MediaAsset row) {
|
||||
return domain.MediaAsset(
|
||||
metadata: _metadataFromRow(row),
|
||||
kind: _mediaKindFromDb(row.kind),
|
||||
localUri: row.localUri,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
durationMs: row.durationMs,
|
||||
checksum: row.checksum,
|
||||
remoteUri: row.remoteUri,
|
||||
thumbnailLocalUri: row.thumbnailLocalUri,
|
||||
);
|
||||
}
|
||||
|
||||
db.ProgramsCompanion _programCompanion(domain.Program program) {
|
||||
final values = _metadataValues(program.metadata);
|
||||
return db.ProgramsCompanion(
|
||||
@ -741,6 +813,17 @@ String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) {
|
||||
domain.ActiveWorkoutStatus.abandoned => 'abandoned',
|
||||
};
|
||||
|
||||
String _mediaKindToDb(domain.MediaKind kind) => switch (kind) {
|
||||
domain.MediaKind.image => 'image',
|
||||
domain.MediaKind.video => 'video',
|
||||
};
|
||||
|
||||
domain.MediaKind _mediaKindFromDb(String value) => switch (value) {
|
||||
'image' => domain.MediaKind.image,
|
||||
'video' => domain.MediaKind.video,
|
||||
_ => throw domain.DomainException('Unknown media kind: $value'),
|
||||
};
|
||||
|
||||
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
|
||||
'running' => domain.ActiveWorkoutStatus.running,
|
||||
'paused' => domain.ActiveWorkoutStatus.paused,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export 'app_database.dart';
|
||||
export 'drift_repositories.dart';
|
||||
export 'local_id.dart';
|
||||
export 'local_media_storage.dart';
|
||||
|
||||
167
lib/infrastructure/local/local_media_storage.dart
Normal file
167
lib/infrastructure/local/local_media_storage.dart
Normal file
@ -0,0 +1,167 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../application/application.dart';
|
||||
import '../../domain/domain.dart';
|
||||
|
||||
final class PathProviderLocalMediaStorage implements LocalMediaStorage {
|
||||
const PathProviderLocalMediaStorage();
|
||||
|
||||
static const _mediaDirectoryName = 'gametime_media';
|
||||
|
||||
@override
|
||||
Future<StoredMediaFile> importFile({
|
||||
required String sourcePath,
|
||||
required MediaKind kind,
|
||||
required String stableFileName,
|
||||
}) async {
|
||||
final source = File(sourcePath);
|
||||
if (!await source.exists()) {
|
||||
throw DomainException('Media source file not found: $sourcePath');
|
||||
}
|
||||
|
||||
final extension = p.extension(source.path).toLowerCase();
|
||||
final targetDirectory = await _kindDirectory(kind);
|
||||
await targetDirectory.create(recursive: true);
|
||||
|
||||
final target = File(
|
||||
p.join(targetDirectory.path, '$stableFileName$extension'),
|
||||
);
|
||||
await source.copy(target.path);
|
||||
|
||||
final bytes = await target.length();
|
||||
final dimensions = kind == MediaKind.image
|
||||
? await _tryReadImageDimensions(target)
|
||||
: null;
|
||||
|
||||
return StoredMediaFile(
|
||||
localUri: target.uri.toString(),
|
||||
mimeType: _mimeTypeForExtension(extension, kind),
|
||||
sizeBytes: bytes,
|
||||
width: dimensions?.width,
|
||||
height: dimensions?.height,
|
||||
durationMs: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<String>> listManagedLocalUris() async {
|
||||
final root = await _rootDirectory();
|
||||
if (!await root.exists()) {
|
||||
return const {};
|
||||
}
|
||||
final files = await root
|
||||
.list(recursive: true)
|
||||
.where((entity) => entity is File)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
return files.map((file) => file.uri.toString()).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteByLocalUri(String localUri) async {
|
||||
final file = File.fromUri(Uri.parse(localUri));
|
||||
final root = await _rootDirectory();
|
||||
final filePath = p.normalize(file.absolute.path);
|
||||
final rootPath = p.normalize(root.absolute.path);
|
||||
if (!p.isWithin(rootPath, filePath) && filePath != rootPath) {
|
||||
throw DomainException(
|
||||
'Refusing to delete unmanaged media file: $localUri',
|
||||
);
|
||||
}
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _kindDirectory(MediaKind kind) async {
|
||||
final root = await _rootDirectory();
|
||||
final child = switch (kind) {
|
||||
MediaKind.image => 'images',
|
||||
MediaKind.video => 'videos',
|
||||
};
|
||||
return Directory(p.join(root.path, child));
|
||||
}
|
||||
|
||||
Future<Directory> _rootDirectory() async {
|
||||
final documents = await getApplicationDocumentsDirectory();
|
||||
return Directory(p.join(documents.path, _mediaDirectoryName));
|
||||
}
|
||||
}
|
||||
|
||||
final class _ImageDimensions {
|
||||
const _ImageDimensions(this.width, this.height);
|
||||
|
||||
final int width;
|
||||
final int height;
|
||||
}
|
||||
|
||||
Future<_ImageDimensions?> _tryReadImageDimensions(File file) async {
|
||||
final bytes = await file.openRead(0, 32).fold<BytesBuilder>(
|
||||
BytesBuilder(copy: false),
|
||||
(builder, chunk) {
|
||||
builder.add(chunk);
|
||||
return builder;
|
||||
},
|
||||
);
|
||||
final data = bytes.toBytes();
|
||||
return _readPngDimensions(data) ?? await _readJpegDimensions(file);
|
||||
}
|
||||
|
||||
_ImageDimensions? _readPngDimensions(Uint8List data) {
|
||||
const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
if (data.length < 24) {
|
||||
return null;
|
||||
}
|
||||
for (var index = 0; index < signature.length; index++) {
|
||||
if (data[index] != signature[index]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
final view = ByteData.sublistView(data);
|
||||
return _ImageDimensions(view.getUint32(16), view.getUint32(20));
|
||||
}
|
||||
|
||||
Future<_ImageDimensions?> _readJpegDimensions(File file) async {
|
||||
final data = await file.readAsBytes();
|
||||
if (data.length < 4 || data[0] != 0xFF || data[1] != 0xD8) {
|
||||
return null;
|
||||
}
|
||||
var offset = 2;
|
||||
while (offset + 9 < data.length) {
|
||||
if (data[offset] != 0xFF) {
|
||||
return null;
|
||||
}
|
||||
final marker = data[offset + 1];
|
||||
final length = (data[offset + 2] << 8) + data[offset + 3];
|
||||
if (length < 2 || offset + 2 + length > data.length) {
|
||||
return null;
|
||||
}
|
||||
if ((marker >= 0xC0 && marker <= 0xC3) ||
|
||||
(marker >= 0xC5 && marker <= 0xC7) ||
|
||||
(marker >= 0xC9 && marker <= 0xCB) ||
|
||||
(marker >= 0xCD && marker <= 0xCF)) {
|
||||
final height = (data[offset + 5] << 8) + data[offset + 6];
|
||||
final width = (data[offset + 7] << 8) + data[offset + 8];
|
||||
return _ImageDimensions(width, height);
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _mimeTypeForExtension(String extension, MediaKind kind) {
|
||||
return switch (extension) {
|
||||
'.jpg' || '.jpeg' => 'image/jpeg',
|
||||
'.png' => 'image/png',
|
||||
'.gif' => 'image/gif',
|
||||
'.webp' => 'image/webp',
|
||||
'.mp4' => 'video/mp4',
|
||||
'.mov' => 'video/quicktime',
|
||||
'.webm' => 'video/webm',
|
||||
_ => kind == MediaKind.image ? 'image/*' : 'video/*',
|
||||
};
|
||||
}
|
||||
188
test/application/media_use_cases_test.dart
Normal file
188
test/application/media_use_cases_test.dart
Normal file
@ -0,0 +1,188 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
|
||||
void main() {
|
||||
test('imports media and stores sync-ready asset metadata', () async {
|
||||
final mediaRepository = _FakeMediaAssetRepository();
|
||||
final useCase = MediaUseCases(
|
||||
mediaRepository: mediaRepository,
|
||||
exerciseRepository: _FakeExerciseRepository(),
|
||||
storage: _FakeMediaStorage(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final asset = await useCase.importMedia(
|
||||
sourcePath: '/tmp/source.png',
|
||||
kind: MediaKind.image,
|
||||
);
|
||||
|
||||
expect(asset.kind, MediaKind.image);
|
||||
expect(asset.localUri, 'file:///app/media/id-1.png');
|
||||
expect(asset.mimeType, 'image/png');
|
||||
expect(asset.sizeBytes, 128);
|
||||
expect(asset.width, 10);
|
||||
expect(asset.height, 20);
|
||||
expect(asset.remoteUri, isNull);
|
||||
expect(asset.checksum, isNull);
|
||||
expect(mediaRepository.saved.single.metadata.id, asset.metadata.id);
|
||||
});
|
||||
|
||||
test('attaches imported image to exercise image field', () async {
|
||||
final exerciseRepository = _FakeExerciseRepository()
|
||||
..exercise = Exercise(
|
||||
metadata: _metadata('exercise-1'),
|
||||
name: 'Squat',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
);
|
||||
final mediaRepository = _FakeMediaAssetRepository()
|
||||
..asset = MediaAsset(
|
||||
metadata: _metadata('media-1'),
|
||||
kind: MediaKind.image,
|
||||
localUri: 'file:///app/media/media-1.png',
|
||||
);
|
||||
final useCase = MediaUseCases(
|
||||
mediaRepository: mediaRepository,
|
||||
exerciseRepository: exerciseRepository,
|
||||
storage: _FakeMediaStorage(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final updated = await useCase.attachToExercise(
|
||||
exerciseId: 'exercise-1',
|
||||
mediaAssetId: 'media-1',
|
||||
);
|
||||
|
||||
expect(updated.imageMediaId, 'media-1');
|
||||
expect(updated.videoMediaId, isNull);
|
||||
expect(exerciseRepository.saved.single.imageMediaId, 'media-1');
|
||||
});
|
||||
|
||||
test('deletes managed files not referenced by active media assets', () async {
|
||||
final storage = _FakeMediaStorage()
|
||||
..managedUris = {
|
||||
'file:///app/media/kept.png',
|
||||
'file:///app/media/orphan.png',
|
||||
};
|
||||
final mediaRepository = _FakeMediaAssetRepository()
|
||||
..activeAssets = [
|
||||
MediaAsset(
|
||||
metadata: _metadata('media-1'),
|
||||
kind: MediaKind.image,
|
||||
localUri: 'file:///app/media/kept.png',
|
||||
),
|
||||
];
|
||||
final useCase = MediaUseCases(
|
||||
mediaRepository: mediaRepository,
|
||||
exerciseRepository: _FakeExerciseRepository(),
|
||||
storage: storage,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final deletedCount = await useCase.deleteOrphanedManagedFiles();
|
||||
|
||||
expect(deletedCount, 1);
|
||||
expect(storage.deletedUris, ['file:///app/media/orphan.png']);
|
||||
});
|
||||
}
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
createdAt: DateTime.utc(2026, 7, 17),
|
||||
updatedAt: DateTime.utc(2026, 7, 17),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
const _FakeClock(this.value);
|
||||
|
||||
final DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
@override
|
||||
String newId() {
|
||||
_next += 1;
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeMediaStorage implements LocalMediaStorage {
|
||||
Set<String> managedUris = const {};
|
||||
final deletedUris = <String>[];
|
||||
|
||||
@override
|
||||
Future<StoredMediaFile> importFile({
|
||||
required String sourcePath,
|
||||
required MediaKind kind,
|
||||
required String stableFileName,
|
||||
}) async {
|
||||
return StoredMediaFile(
|
||||
localUri: 'file:///app/media/$stableFileName.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 128,
|
||||
width: 10,
|
||||
height: 20,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<String>> listManagedLocalUris() async => managedUris;
|
||||
|
||||
@override
|
||||
Future<void> deleteByLocalUri(String localUri) async {
|
||||
deletedUris.add(localUri);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeMediaAssetRepository implements MediaAssetRepository {
|
||||
MediaAsset? asset;
|
||||
List<MediaAsset> activeAssets = const [];
|
||||
final saved = <MediaAsset>[];
|
||||
|
||||
@override
|
||||
Future<MediaAsset?> findById(String id) async => asset;
|
||||
|
||||
@override
|
||||
Future<List<MediaAsset>> listActive() async => activeAssets;
|
||||
|
||||
@override
|
||||
Future<void> save(MediaAsset mediaAsset) async {
|
||||
saved.add(mediaAsset);
|
||||
asset = mediaAsset;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeExerciseRepository implements ExerciseRepository {
|
||||
Exercise? exercise;
|
||||
final saved = <Exercise>[];
|
||||
|
||||
@override
|
||||
Future<Exercise?> findById(String id) async => exercise;
|
||||
|
||||
@override
|
||||
Future<List<Exercise>> listActive() async {
|
||||
return exercise == null ? const [] : [exercise!];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(Exercise exercise) async {
|
||||
saved.add(exercise);
|
||||
this.exercise = exercise;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user