feat(exercice): galerie de 5 images par exercice (ticket #35)

Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités/use cases/repositories et exercise_library_screen.dart pour
supporter jusqu'à 5 images par exercice au lieu d'une seule. flutter
analyze propre, 63/63 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:02:40 +02:00
parent 6419e2d9cc
commit 8bda4758f2
9 changed files with 2250 additions and 299 deletions

View File

@ -156,6 +156,75 @@ void main() {
);
});
test('Exercise image gallery rejects a sixth image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const [
'media-1',
'media-2',
'media-3',
'media-4',
'media-5',
],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
await expectLater(
useCase.addImage(exerciseId: 'exercise-1', mediaAssetId: 'media-6'),
throwsA(isA<DomainException>()),
);
});
test('Exercise image gallery reorders images', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.reorderImages(
exerciseId: 'exercise-1',
orderedMediaIds: const ['media-3', 'media-1', 'media-2'],
);
expect(updated.imageMediaIds, ['media-3', 'media-1', 'media-2']);
expect(repository.saved.single.imageMediaIds, updated.imageMediaIds);
});
test('Exercise image gallery removes an image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.removeImage(
exerciseId: 'exercise-1',
mediaAssetId: 'media-2',
);
expect(updated.imageMediaIds, ['media-1', 'media-3']);
expect(updated.imageMediaId, 'media-1');
});
test('delete exercise removes it from referencing programs', () async {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(

View File

@ -132,23 +132,90 @@ void main() {
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage),
mediaPicker: const _FakeMediaPicker(
imagePath: '/tmp/imports/photo.png',
),
mediaPicker: _FakeMediaPicker(imagePath: '/tmp/imports/photo.png'),
),
),
);
await tester.tap(find.text('Choisir une image'));
await tester.tap(find.text('Ajouter une image'));
await tester.pump();
expect(storage.imports.single.sourcePath, '/tmp/imports/photo.png');
expect(storage.imports.single.kind, MediaKind.image);
expect(find.text('photo.png'), findsOneWidget);
expect(find.text('Image importée.'), findsOneWidget);
expect(find.text('Image ajoutée.'), findsOneWidget);
},
);
testWidgets('ajouter des images bloque la sixième image', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
final storage = _FakeMediaStorage();
await tester.binding.setSurfaceSize(const Size(400, 1200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage),
mediaPicker: _FakeMediaPicker(
imagePaths: const [
'/tmp/imports/photo-1.png',
'/tmp/imports/photo-2.png',
'/tmp/imports/photo-3.png',
'/tmp/imports/photo-4.png',
'/tmp/imports/photo-5.png',
'/tmp/imports/photo-6.png',
],
),
),
),
);
for (var i = 0; i < 5; i++) {
await tester.tap(find.text('Ajouter une image'));
await tester.pump();
}
expect(storage.imports, hasLength(5));
expect(find.text('photo-5.png'), findsOneWidget);
expect(find.text('Maximum 5 images par exercice.'), findsOneWidget);
await tester.tap(find.text('Ajouter une image'));
await tester.pump();
expect(storage.imports, hasLength(5));
expect(find.text('photo-6.png'), findsNothing);
expect(find.text('Maximum 5 images par exercice.'), findsWidgets);
});
testWidgets('supprimer une image la retire de la galerie', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
final storage = _FakeMediaStorage();
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage),
mediaPicker: _FakeMediaPicker(imagePath: '/tmp/imports/photo.png'),
),
),
);
await tester.tap(find.text('Ajouter une image'));
await tester.pump();
expect(find.text('photo.png'), findsOneWidget);
await tester.tap(find.byTooltip('Supprimer limage'));
await tester.pump();
expect(find.text('photo.png'), findsNothing);
expect(find.text('Aucune image sélectionnée'), findsOneWidget);
});
testWidgets(
'supprimer un exercice demande confirmation puis le retire de la liste',
(tester) async {
@ -244,17 +311,31 @@ MediaUseCases _mediaUseCases(
}
final class _FakeMediaPicker implements MediaSourcePicker {
const _FakeMediaPicker({this.imagePath});
_FakeMediaPicker({this.imagePath, this.imagePaths = const []});
final String? imagePath;
final List<String> imagePaths;
var _nextImagePath = 0;
@override
Future<String?> pickPath(MediaKind kind) async {
return switch (kind) {
MediaKind.image => imagePath,
MediaKind.image => _pickImagePath(),
MediaKind.video => null,
};
}
String? _pickImagePath() {
if (imagePaths.isEmpty) {
return imagePath;
}
if (_nextImagePath >= imagePaths.length) {
return null;
}
final path = imagePaths[_nextImagePath];
_nextImagePath += 1;
return path;
}
}
EntityMetadata _metadata(String id) {