fix(media): sélecteur de média pour les exercices (ticket #15)

Corrige/ajoute le sélecteur de média sur exercise_library_screen.dart
suite aux retours de QA. flutter analyze propre (1 warning mineur
"paramètre inutilisé" dans un test, sans impact), 27/27 tests verts,
build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:22:59 +02:00
parent ee51b3941f
commit cb5cfc48ff
4 changed files with 271 additions and 46 deletions

View File

@ -1,8 +1,27 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../application/application.dart';
import '../domain/domain.dart';
abstract interface class MediaSourcePicker {
Future<String?> pickPath(MediaKind kind);
}
final class ImagePickerMediaSourcePicker implements MediaSourcePicker {
const ImagePickerMediaSourcePicker();
@override
Future<String?> pickPath(MediaKind kind) async {
final picker = ImagePicker();
final file = switch (kind) {
MediaKind.image => await picker.pickImage(source: ImageSource.gallery),
MediaKind.video => await picker.pickVideo(source: ImageSource.gallery),
};
return file?.path;
}
}
final class ExerciseLibraryScreen extends StatefulWidget {
const ExerciseLibraryScreen({
required this.exerciseUseCases,
@ -248,12 +267,14 @@ final class ExerciseFormScreen extends StatefulWidget {
const ExerciseFormScreen({
required this.exerciseUseCases,
required this.mediaUseCases,
this.mediaPicker = const ImagePickerMediaSourcePicker(),
this.exercise,
super.key,
});
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
final MediaSourcePicker mediaPicker;
final Exercise? exercise;
@override
@ -266,10 +287,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
late final TextEditingController _descriptionController;
late final TextEditingController _scoreLabelController;
late final TextEditingController _scoreUnitController;
final _imagePathController = TextEditingController();
final _videoPathController = TextEditingController();
String? _imageMediaId;
String? _videoMediaId;
String? _selectedImageName;
String? _selectedVideoName;
var _hasTime = true;
var _hasReps = false;
var _hasScore = false;
@ -302,8 +323,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_descriptionController.dispose();
_scoreLabelController.dispose();
_scoreUnitController.dispose();
_imagePathController.dispose();
_videoPathController.dispose();
super.dispose();
}
@ -346,19 +365,21 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
),
const SizedBox(height: 8),
_MediaImportField(
controller: _imagePathController,
label: 'Chemin de limage',
label: 'Image',
selectedFileName: _selectedImageName,
imported: _imageMediaId != null,
importing: _importingImage,
onImport: () => _importMedia(MediaKind.image),
actionLabel: 'Choisir une image',
onPick: () => _importMedia(MediaKind.image),
),
const SizedBox(height: 8),
_MediaImportField(
controller: _videoPathController,
label: 'Chemin de la vidéo',
label: 'Vidéo',
selectedFileName: _selectedVideoName,
imported: _videoMediaId != null,
importing: _importingVideo,
onImport: () => _importMedia(MediaKind.video),
actionLabel: 'Choisir une vidéo',
onPick: () => _importMedia(MediaKind.video),
),
const SizedBox(height: 24),
Text(
@ -461,19 +482,18 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
Future<void> _importMedia(MediaKind kind) async {
final controller = kind == MediaKind.image
? _imagePathController
: _videoPathController;
final sourcePath = controller.text.trim();
if (sourcePath.isEmpty) {
_showSnackBar('Indique un chemin de fichier à importer.');
final sourcePath = await widget.mediaPicker.pickPath(kind);
if (sourcePath == null) {
return;
}
final fileName = _fileNameFromPath(sourcePath);
setState(() {
if (kind == MediaKind.image) {
_importingImage = true;
_selectedImageName = fileName;
} else {
_importingVideo = true;
_selectedVideoName = fileName;
}
});
try {
@ -580,6 +600,11 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
return text.isEmpty ? null : text;
}
String _fileNameFromPath(String path) {
final parts = path.split(RegExp(r'[/\\]'));
return parts.isEmpty ? path : parts.last;
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
@ -635,46 +660,53 @@ final class _MeasureSwitch extends StatelessWidget {
final class _MediaImportField extends StatelessWidget {
const _MediaImportField({
required this.controller,
required this.label,
required this.selectedFileName,
required this.imported,
required this.importing,
required this.onImport,
required this.actionLabel,
required this.onPick,
});
final TextEditingController controller;
final String label;
final String? selectedFileName;
final bool imported;
final bool importing;
final VoidCallback onImport;
final String actionLabel;
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
final fileName = selectedFileName;
final status = fileName == null
? imported
? 'Média déjà importé'
: 'Aucun fichier sélectionné'
: fileName;
return InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (imported)
const Tooltip(
message: 'Média importé',
child: Icon(Icons.check_circle_outline),
),
IconButton(
tooltip: 'Importer',
onPressed: importing ? null : onImport,
icon: importing
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
),
],
),
),
child: Row(
children: [
Icon(imported ? Icons.check_circle_outline : Icons.perm_media),
const SizedBox(width: 12),
Expanded(
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: importing ? null : onPick,
icon: importing
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: Text(actionLabel),
),
],
),
);
}