From c77006f32228ba64b2685bb4ed394c3b91f10a0e Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 18:31:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(media):=20gestion=20des=20m=C3=A9dias=20lo?= =?UTF-8?q?caux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/application/ports.dart | 35 ++++ lib/application/use_cases.dart | 117 +++++++++++ .../local/drift_repositories.dart | 83 ++++++++ lib/infrastructure/local/local.dart | 1 + .../local/local_media_storage.dart | 167 ++++++++++++++++ test/application/media_use_cases_test.dart | 188 ++++++++++++++++++ 6 files changed, 591 insertions(+) create mode 100644 lib/infrastructure/local/local_media_storage.dart create mode 100644 test/application/media_use_cases_test.dart diff --git a/lib/application/ports.dart b/lib/application/ports.dart index f83ebab..f6f74bd 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -21,6 +21,41 @@ abstract interface class ExerciseRepository { Future 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 importFile({ + required String sourcePath, + required MediaKind kind, + required String stableFileName, + }); + + Future> listManagedLocalUris(); + Future deleteByLocalUri(String localUri); +} + +abstract interface class MediaAssetRepository { + Future findById(String id); + Future> listActive(); + Future save(MediaAsset mediaAsset); +} + abstract interface class ProgramRepository { Future findById(String id); Future save(Program program); diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index d43c94c..b2d37db 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -46,6 +46,38 @@ final class ExerciseUseCases { Future save(Exercise exercise) => repository.save(exercise); + Future 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 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 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 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 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 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, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 35e212d..7ebdcf2 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -35,6 +35,35 @@ final class DriftExerciseRepository implements ExerciseRepository { } } +final class DriftMediaAssetRepository implements MediaAssetRepository { + const DriftMediaAssetRepository(this.database); + + final db.AppDatabase database; + + @override + Future 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> listActive() async { + final rows = await (database.select( + database.mediaAssets, + )..where((table) => table.deletedAt.isNull())).get(); + return rows.map(_mediaAssetFromRow).toList(); + } + + @override + Future 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, diff --git a/lib/infrastructure/local/local.dart b/lib/infrastructure/local/local.dart index 54d2676..6094f78 100644 --- a/lib/infrastructure/local/local.dart +++ b/lib/infrastructure/local/local.dart @@ -1,3 +1,4 @@ export 'app_database.dart'; export 'drift_repositories.dart'; export 'local_id.dart'; +export 'local_media_storage.dart'; diff --git a/lib/infrastructure/local/local_media_storage.dart b/lib/infrastructure/local/local_media_storage.dart new file mode 100644 index 0000000..f515175 --- /dev/null +++ b/lib/infrastructure/local/local_media_storage.dart @@ -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 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> 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() + .toList(); + return files.map((file) => file.uri.toString()).toSet(); + } + + @override + Future 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 _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 _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(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/*', + }; +} diff --git a/test/application/media_use_cases_test.dart b/test/application/media_use_cases_test.dart new file mode 100644 index 0000000..ebd5a55 --- /dev/null +++ b/test/application/media_use_cases_test.dart @@ -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 managedUris = const {}; + final deletedUris = []; + + @override + Future 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> listManagedLocalUris() async => managedUris; + + @override + Future deleteByLocalUri(String localUri) async { + deletedUris.add(localUri); + } +} + +final class _FakeMediaAssetRepository implements MediaAssetRepository { + MediaAsset? asset; + List activeAssets = const []; + final saved = []; + + @override + Future findById(String id) async => asset; + + @override + Future> listActive() async => activeAssets; + + @override + Future save(MediaAsset mediaAsset) async { + saved.add(mediaAsset); + asset = mediaAsset; + } +} + +final class _FakeExerciseRepository implements ExerciseRepository { + Exercise? exercise; + final saved = []; + + @override + Future findById(String id) async => exercise; + + @override + Future> listActive() async { + return exercise == null ? const [] : [exercise!]; + } + + @override + Future save(Exercise exercise) async { + saved.add(exercise); + this.exercise = exercise; + } +}