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

@ -3,6 +3,7 @@ import 'package:image_picker/image_picker.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'theme.dart';
abstract interface class MediaSourcePicker {
Future<String?> pickPath(MediaKind kind);
@ -245,9 +246,9 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(SnackBar(content: Text(message)));
}
}
@ -355,9 +356,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
late final TextEditingController _defaultRepsController;
late final TextEditingController _defaultScoreController;
late final TextEditingController _defaultScoreTimeController;
String? _imageMediaId;
late List<String> _imageMediaIds;
String? _videoMediaId;
String? _selectedImageName;
final _selectedImageNamesById = <String, String>{};
String? _selectedVideoName;
var _hasTime = true;
var _hasReps = false;
@ -393,7 +394,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs),
),
);
_imageMediaId = exercise?.imageMediaId;
_imageMediaIds = List<String>.of(exercise?.imageMediaIds ?? const []);
_videoMediaId = exercise?.videoMediaId;
_hasTime = exercise?.hasTimeMeasure ?? true;
_hasReps = exercise?.hasRepsMeasure ?? false;
@ -446,13 +447,12 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_MediaImportField(
label: 'Image',
selectedFileName: _selectedImageName,
imported: _imageMediaId != null,
_ImageGalleryField(
imageMediaIds: _imageMediaIds,
imageNamesById: _selectedImageNamesById,
importing: _importingImage,
actionLabel: 'Choisir une image',
onPick: () => _importMedia(MediaKind.image),
onAdd: _importImage,
onRemove: _removeImage,
),
const SizedBox(height: 8),
_MediaImportField(
@ -461,7 +461,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
imported: _videoMediaId != null,
importing: _importingVideo,
actionLabel: 'Choisir une vidéo',
onPick: () => _importMedia(MediaKind.video),
onPick: _importVideo,
),
const SizedBox(height: 24),
Text(
@ -668,43 +668,71 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
});
}
Future<void> _importMedia(MediaKind kind) async {
final sourcePath = await widget.mediaPicker.pickPath(kind);
Future<void> _importImage() async {
if (_imageMediaIds.length >= 5) {
_showSnackBar('Maximum 5 images par exercice.');
return;
}
final sourcePath = await widget.mediaPicker.pickPath(MediaKind.image);
if (sourcePath == null) {
return;
}
final fileName = _fileNameFromPath(sourcePath);
setState(() => _importingImage = true);
try {
final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath,
kind: MediaKind.image,
);
if (!mounted) return;
setState(() {
_imageMediaIds = [..._imageMediaIds, asset.metadata.id];
_selectedImageNamesById[asset.metadata.id] = fileName;
});
_showSnackBar('Image ajoutée.');
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _importingImage = false);
}
}
}
void _removeImage(String mediaAssetId) {
setState(() {
_imageMediaIds = _imageMediaIds
.where((imageId) => imageId != mediaAssetId)
.toList();
_selectedImageNamesById.remove(mediaAssetId);
});
}
Future<void> _importVideo() async {
final sourcePath = await widget.mediaPicker.pickPath(MediaKind.video);
if (sourcePath == null) {
return;
}
final fileName = _fileNameFromPath(sourcePath);
setState(() {
if (kind == MediaKind.image) {
_importingImage = true;
_selectedImageName = fileName;
} else {
_importingVideo = true;
_selectedVideoName = fileName;
}
_importingVideo = true;
_selectedVideoName = fileName;
});
try {
final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath,
kind: kind,
kind: MediaKind.video,
);
if (!mounted) return;
setState(() {
if (kind == MediaKind.image) {
_imageMediaId = asset.metadata.id;
} else {
_videoMediaId = asset.metadata.id;
}
_videoMediaId = asset.metadata.id;
});
_showSnackBar(
kind == MediaKind.image ? 'Image importée.' : 'Vidéo importée.',
);
_showSnackBar('Vidéo importée.');
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() {
_importingImage = false;
_importingVideo = false;
});
}
@ -766,7 +794,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
await widget.exerciseUseCases.create(
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId,
imageMediaIds: _imageMediaIds,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
@ -784,7 +812,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
id: exercise.metadata.id,
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId,
imageMediaIds: _imageMediaIds,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
@ -937,6 +965,130 @@ final class _MeasureSwitch extends StatelessWidget {
}
}
final class _ImageGalleryField extends StatelessWidget {
const _ImageGalleryField({
required this.imageMediaIds,
required this.imageNamesById,
required this.importing,
required this.onAdd,
required this.onRemove,
});
final List<String> imageMediaIds;
final Map<String, String> imageNamesById;
final bool importing;
final VoidCallback onAdd;
final ValueChanged<String> onRemove;
@override
Widget build(BuildContext context) {
final canAdd = imageMediaIds.length < 5;
return InputDecorator(
decoration: const InputDecoration(labelText: 'Images'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (imageMediaIds.isEmpty)
const Text('Aucune image sélectionnée')
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var index = 0; index < imageMediaIds.length; index++)
_ImageThumbnail(
mediaAssetId: imageMediaIds[index],
label:
imageNamesById[imageMediaIds[index]] ??
'Image ${index + 1}',
onRemove: onRemove,
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: importing ? null : onAdd,
icon: importing
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_photo_alternate_outlined),
label: const Text('Ajouter une image'),
),
),
if (!canAdd) ...[
const SizedBox(height: 8),
Text(
'Maximum 5 images par exercice.',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
);
}
}
final class _ImageThumbnail extends StatelessWidget {
const _ImageThumbnail({
required this.mediaAssetId,
required this.label,
required this.onRemove,
});
final String mediaAssetId;
final String label;
final ValueChanged<String> onRemove;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return SizedBox(
width: 104,
child: Stack(
children: [
Container(
height: 104,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border),
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.image_outlined),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
Positioned(
top: 2,
right: 2,
child: IconButton.filledTonal(
tooltip: 'Supprimer limage',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, size: 18),
onPressed: () => onRemove(mediaAssetId),
),
),
],
),
);
}
}
final class _MediaImportField extends StatelessWidget {
const _MediaImportField({
required this.label,