merge(main): sélecteur de média pour les exercices (ticket #15)

Fusionne feature/#15-selecteur-media — analyze propre (1 warning
mineur sans impact), 27/27 tests verts, build APK debug validé.
Clôture le lot de correctifs post-QA (tickets #13, #14, #15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:23:10 +02:00
7 changed files with 278 additions and 53 deletions

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#15" issueRef: "#15"
version: 2 version: 3
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784319493575 updatedAt: 1784319755572
--- ---

View File

@ -2,7 +2,7 @@
id: "3463569f-e837-4af9-8156-4968e178d4e1" id: "3463569f-e837-4af9-8156-4968e178d4e1"
number: 15 number: 15
title: "[DevFrontend] Sélecteur de fichier pour l'import média (remplace la saisie manuelle de chemin)" title: "[DevFrontend] Sélecteur de fichier pour l'import média (remplace la saisie manuelle de chemin)"
status: "inProgress" status: "closed"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [{"target":"#6","kind":"relatesTo"}] links: [{"target":"#6","kind":"relatesTo"}]
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784318999121 createdAt: 1784318999121
updatedAt: 1784319493575 updatedAt: 1784319755572
version: 2 version: 3
--- ---
L'écran de création/édition d'exercice (lib/presentation/exercise_library_screen.dart) fait actuellement saisir manuellement un chemin de fichier pour importer une image/vidéo. Remplacer par un vrai sélecteur de fichiers via le package `image_picker` (à ajouter en dépendance dans pubspec.yaml — nouvelle dépendance, signalée et acceptée par l'utilisateur). Garder MediaUseCases.importMedia tel quel côté logique métier, seul le déclenchement du chemin source change (sélection graphique au lieu de TextField). L'écran de création/édition d'exercice (lib/presentation/exercise_library_screen.dart) fait actuellement saisir manuellement un chemin de fichier pour importer une image/vidéo. Remplacer par un vrai sélecteur de fichiers via le package `image_picker` (à ajouter en dépendance dans pubspec.yaml — nouvelle dépendance, signalée et acceptée par l'utilisateur). Garder MediaUseCases.importMedia tel quel côté logique métier, seul le déclenchement du chemin source change (sélection graphique au lieu de TextField).

View File

@ -173,13 +173,13 @@
"issueRef": "#15", "issueRef": "#15",
"path": "15", "path": "15",
"title": "[DevFrontend] Sélecteur de fichier pour l'import média (remplace la saisie manuelle de chemin)", "title": "[DevFrontend] Sélecteur de fichier pour l'import média (remplace la saisie manuelle de chemin)",
"status": "inProgress", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d" "9933c93a-b8a1-4164-a3bb-7063fdad747d"
], ],
"updatedAt": 1784319493575 "updatedAt": 1784319755572
} }
] ]
} }

View File

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

View File

@ -153,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@ -225,6 +233,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -246,11 +286,24 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@ -275,6 +328,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" 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: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -291,6 +352,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" 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: io:
dependency: transitive dependency: transitive
description: description:
@ -721,5 +846,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.44.0"

View File

@ -13,6 +13,7 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
drift: ^2.34.2 drift: ^2.34.2
drift_flutter: ^0.3.1 drift_flutter: ^0.3.1
image_picker: ^1.2.3
path: ^1.9.1 path: ^1.9.1
path_provider: ^2.1.5 path_provider: ^2.1.5
sqlite3_flutter_libs: ^0.6.0+eol sqlite3_flutter_libs: ^0.6.0+eol

View File

@ -65,6 +65,34 @@ void main() {
expect(find.text('Répétitions'), findsOneWidget); expect(find.text('Répétitions'), findsOneWidget);
expect(find.text('Score (kg)'), findsOneWidget); expect(find.text('Score (kg)'), findsOneWidget);
}); });
testWidgets(
'sélectionner une image déclenche limport 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) { ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
@ -76,17 +104,35 @@ ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
); );
} }
MediaUseCases _mediaUseCases(_FakeExerciseRepository exerciseRepository) { MediaUseCases _mediaUseCases(
_FakeExerciseRepository exerciseRepository, {
_FakeMediaStorage? storage,
}) {
return MediaUseCases( return MediaUseCases(
mediaRepository: _FakeMediaAssetRepository(), mediaRepository: _FakeMediaAssetRepository(),
exerciseRepository: exerciseRepository, exerciseRepository: exerciseRepository,
storage: _FakeMediaStorage(), storage: storage ?? _FakeMediaStorage(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1', 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) { EntityMetadata _metadata(String id) {
return EntityMetadata( return EntityMetadata(
id: id, id: id,
@ -167,6 +213,8 @@ final class _FakeMediaAssetRepository implements MediaAssetRepository {
} }
final class _FakeMediaStorage implements LocalMediaStorage { final class _FakeMediaStorage implements LocalMediaStorage {
final imports = <_MediaImportCall>[];
@override @override
Future<void> deleteByLocalUri(String localUri) async {} Future<void> deleteByLocalUri(String localUri) async {}
@ -176,6 +224,13 @@ final class _FakeMediaStorage implements LocalMediaStorage {
required MediaKind kind, required MediaKind kind,
required String stableFileName, required String stableFileName,
}) async { }) async {
imports.add(
_MediaImportCall(
sourcePath: sourcePath,
kind: kind,
stableFileName: stableFileName,
),
);
return StoredMediaFile( return StoredMediaFile(
localUri: 'file:///tmp/$stableFileName', localUri: 'file:///tmp/$stableFileName',
mimeType: kind == MediaKind.image ? 'image/png' : 'video/mp4', mimeType: kind == MediaKind.image ? 'image/png' : 'video/mp4',
@ -186,3 +241,15 @@ final class _FakeMediaStorage implements LocalMediaStorage {
@override @override
Future<Set<String>> listManagedLocalUris() async => const {}; 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;
}