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:
@ -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 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<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,47 +660,54 @@ 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,
|
||||
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: 18,
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: Text(actionLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
129
pubspec.lock
129
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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<String?> 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<void> 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<Set<String>> 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user