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:
2026-07-17 18:31:28 +02:00
parent 2499a95e08
commit c77006f322
6 changed files with 591 additions and 0 deletions

View File

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