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:
@ -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 l’image',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: () => onRemove(mediaAssetId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _MediaImportField extends StatelessWidget {
|
||||
const _MediaImportField({
|
||||
required this.label,
|
||||
|
||||
Reference in New Issue
Block a user