From cb5cfc48ffd630dc4f5064d7cd3868023141b1a5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 22:22:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(media):=20s=C3=A9lecteur=20de=20m=C3=A9dia?= =?UTF-8?q?=20pour=20les=20exercices=20(ticket=20#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/presentation/exercise_library_screen.dart | 116 ++++++++++------ pubspec.lock | 129 +++++++++++++++++- pubspec.yaml | 1 + .../exercise_library_screen_test.dart | 71 +++++++++- 4 files changed, 271 insertions(+), 46 deletions(-) diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 46a0433..3bbdda4 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -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 pickPath(MediaKind kind); +} + +final class ImagePickerMediaSourcePicker implements MediaSourcePicker { + const ImagePickerMediaSourcePicker(); + + @override + Future 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 { 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 { _descriptionController.dispose(); _scoreLabelController.dispose(); _scoreUnitController.dispose(); - _imagePathController.dispose(); - _videoPathController.dispose(); super.dispose(); } @@ -346,19 +365,21 @@ final class _ExerciseFormScreenState extends State { ), const SizedBox(height: 8), _MediaImportField( - controller: _imagePathController, - label: 'Chemin de l’image', + 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 { } Future _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 { 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), + ), + ], ), ); } diff --git a/pubspec.lock b/pubspec.lock index 2b6f5c2..560d28b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" crypto: dependency: transitive description: @@ -225,6 +233,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -246,11 +286,24 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -275,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -291,6 +352,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" io: dependency: transitive description: @@ -721,5 +846,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index e3eb54b..8ae3487 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: cupertino_icons: ^1.0.8 drift: ^2.34.2 drift_flutter: ^0.3.1 + image_picker: ^1.2.3 path: ^1.9.1 path_provider: ^2.1.5 sqlite3_flutter_libs: ^0.6.0+eol diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index 1f8ca56..e7d5769 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -65,6 +65,34 @@ void main() { expect(find.text('Répétitions'), findsOneWidget); expect(find.text('Score (kg)'), findsOneWidget); }); + + testWidgets( + 'sélectionner une image déclenche l’import et confirme le fichier', + (tester) async { + final exerciseRepository = _FakeExerciseRepository(); + final storage = _FakeMediaStorage(); + + await tester.pumpWidget( + MaterialApp( + home: ExerciseFormScreen( + exerciseUseCases: _exerciseUseCases(exerciseRepository), + mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage), + mediaPicker: const _FakeMediaPicker( + imagePath: '/tmp/imports/photo.png', + ), + ), + ), + ); + + await tester.tap(find.text('Choisir 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); + }, + ); } ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { @@ -76,17 +104,35 @@ ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { ); } -MediaUseCases _mediaUseCases(_FakeExerciseRepository exerciseRepository) { +MediaUseCases _mediaUseCases( + _FakeExerciseRepository exerciseRepository, { + _FakeMediaStorage? storage, +}) { return MediaUseCases( mediaRepository: _FakeMediaAssetRepository(), exerciseRepository: exerciseRepository, - storage: _FakeMediaStorage(), + storage: storage ?? _FakeMediaStorage(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } +final class _FakeMediaPicker implements MediaSourcePicker { + const _FakeMediaPicker({this.imagePath, this.videoPath}); + + final String? imagePath; + final String? videoPath; + + @override + Future pickPath(MediaKind kind) async { + return switch (kind) { + MediaKind.image => imagePath, + MediaKind.video => videoPath, + }; + } +} + EntityMetadata _metadata(String id) { return EntityMetadata( id: id, @@ -167,6 +213,8 @@ final class _FakeMediaAssetRepository implements MediaAssetRepository { } final class _FakeMediaStorage implements LocalMediaStorage { + final imports = <_MediaImportCall>[]; + @override Future deleteByLocalUri(String localUri) async {} @@ -176,6 +224,13 @@ final class _FakeMediaStorage implements LocalMediaStorage { required MediaKind kind, required String stableFileName, }) async { + imports.add( + _MediaImportCall( + sourcePath: sourcePath, + kind: kind, + stableFileName: stableFileName, + ), + ); return StoredMediaFile( localUri: 'file:///tmp/$stableFileName', mimeType: kind == MediaKind.image ? 'image/png' : 'video/mp4', @@ -186,3 +241,15 @@ final class _FakeMediaStorage implements LocalMediaStorage { @override Future> listManagedLocalUris() async => const {}; } + +final class _MediaImportCall { + const _MediaImportCall({ + required this.sourcePath, + required this.kind, + required this.stableFileName, + }); + + final String sourcePath; + final MediaKind kind; + final String stableFileName; +}