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, required this.mediaUseCases, super.key, }); final ExerciseUseCases exerciseUseCases; final MediaUseCases mediaUseCases; @override State createState() => _ExerciseLibraryScreenState(); } final class _ExerciseLibraryScreenState extends State { final _searchController = TextEditingController(); var _selectedMeasures = {}; late Future> _exercises; @override void initState() { super.initState(); _exercises = widget.exerciseUseCases.listActive(); _searchController.addListener(() => setState(() {})); } @override void dispose() { _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Exercices')), floatingActionButton: FloatingActionButton.extended( onPressed: () => _openForm(), icon: const Icon(Icons.add), label: const Text('Créer'), ), body: FutureBuilder>( future: _exercises, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return _CenteredMessage( title: 'Impossible de charger les exercices', actionLabel: 'Réessayer', onAction: _reload, ); } final exercises = snapshot.data ?? const []; final filtered = _filter(exercises); return RefreshIndicator( onRefresh: () { _reload(); return Future.value(); }, child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _searchController, decoration: const InputDecoration( labelText: 'Rechercher', prefixIcon: Icon(Icons.search), ), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: WorkoutMeasure.values.map((measure) { return FilterChip( label: Text(measure.label), selected: _selectedMeasures.contains(measure), onSelected: (selected) { setState(() { if (selected) { _selectedMeasures = { ..._selectedMeasures, measure, }; } else { _selectedMeasures = {..._selectedMeasures} ..remove(measure); } }); }, ); }).toList(), ), ], ), ), ), if (exercises.isEmpty) SliverFillRemaining( hasScrollBody: false, child: _CenteredMessage( title: 'Aucun exercice pour le moment', message: 'Crée ton premier exercice pour composer tes programmes.', actionLabel: 'Créer un exercice', onAction: () => _openForm(), ), ) else if (filtered.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: _CenteredMessage( title: 'Aucun exercice ne correspond', message: 'Modifie la recherche ou les filtres de mesures.', ), ) else SliverList( delegate: SliverChildBuilderDelegate((context, index) { if (index.isOdd) { return const Divider(height: 1, indent: 72); } final exercise = filtered[index ~/ 2]; return ExerciseListTile( exercise: exercise, onTap: () => _openForm(exercise: exercise), ); }, childCount: filtered.length * 2 - 1), ), ], ), ); }, ), ); } List _filter(List exercises) { final query = _searchController.text.trim().toLowerCase(); return exercises.where((exercise) { final matchesQuery = query.isEmpty || exercise.name.toLowerCase().contains(query) || (exercise.description?.toLowerCase().contains(query) ?? false); final matchesMeasures = _selectedMeasures.isEmpty || _selectedMeasures.every(exercise.availableMeasures.contains); return matchesQuery && matchesMeasures; }).toList(); } void _reload() { setState(() { _exercises = widget.exerciseUseCases.listActive(); }); } Future _openForm({Exercise? exercise}) async { final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ExerciseFormScreen( exerciseUseCases: widget.exerciseUseCases, mediaUseCases: widget.mediaUseCases, exercise: exercise, ), ), ); if (changed == true) { if (!mounted) return; _reload(); } } } final class ExerciseListTile extends StatelessWidget { const ExerciseListTile({required this.exercise, this.onTap, super.key}); final Exercise exercise; final VoidCallback? onTap; @override Widget build(BuildContext context) { final description = exercise.description?.trim(); final hasMedia = exercise.imageMediaId != null || exercise.videoMediaId != null; return ListTile( onTap: onTap, leading: CircleAvatar( child: Icon( hasMedia ? Icons.perm_media_outlined : Icons.fitness_center, ), ), title: Text(exercise.name), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (description != null && description.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2, bottom: 6), child: Text( description, maxLines: 2, overflow: TextOverflow.ellipsis, ), ) else const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 6, children: [ ...exercise.availableMeasures.map( (measure) => MeasureBadge( measure: measure, scoreUnit: exercise.scoreUnit, scoreInputMode: exercise.scoreInputMode, ), ), if (hasMedia) const Tooltip( message: 'Média associé', child: Icon(Icons.image_outlined, size: 18), ), ], ), ], ), trailing: const Icon(Icons.chevron_right), ); } } 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 State createState() => _ExerciseFormScreenState(); } final class _ExerciseFormScreenState extends State { final _formKey = GlobalKey(); late final TextEditingController _nameController; late final TextEditingController _descriptionController; late final TextEditingController _scoreLabelController; late final TextEditingController _scoreUnitController; String? _imageMediaId; String? _videoMediaId; String? _selectedImageName; String? _selectedVideoName; var _hasTime = true; var _hasReps = false; var _hasScore = false; var _scoreInputMode = ScoreInputMode.manual; var _saving = false; var _importingImage = false; var _importingVideo = false; String? _measureError; String? _warning; bool get _isEditing => widget.exercise != null; @override void initState() { super.initState(); final exercise = widget.exercise; _nameController = TextEditingController(text: exercise?.name); _descriptionController = TextEditingController(text: exercise?.description); _scoreLabelController = TextEditingController(text: exercise?.scoreLabel); _scoreUnitController = TextEditingController(text: exercise?.scoreUnit); _imageMediaId = exercise?.imageMediaId; _videoMediaId = exercise?.videoMediaId; _hasTime = exercise?.hasTimeMeasure ?? true; _hasReps = exercise?.hasRepsMeasure ?? false; _hasScore = exercise?.hasScoreMeasure ?? false; _scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual; } @override void dispose() { _nameController.dispose(); _descriptionController.dispose(); _scoreLabelController.dispose(); _scoreUnitController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(_isEditing ? 'Modifier l’exercice' : 'Créer un exercice'), ), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ TextFormField( controller: _nameController, decoration: const InputDecoration(labelText: 'Nom'), textInputAction: TextInputAction.next, validator: (value) => value == null || value.trim().isEmpty ? 'Le nom est obligatoire.' : null, ), const SizedBox(height: 12), TextFormField( controller: _descriptionController, decoration: const InputDecoration(labelText: 'Description'), minLines: 2, maxLines: 4, ), const SizedBox(height: 24), Text( 'Média optionnel', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), _MediaImportField( label: 'Image', selectedFileName: _selectedImageName, imported: _imageMediaId != null, importing: _importingImage, actionLabel: 'Choisir une image', onPick: () => _importMedia(MediaKind.image), ), const SizedBox(height: 8), _MediaImportField( label: 'Vidéo', selectedFileName: _selectedVideoName, imported: _videoMediaId != null, importing: _importingVideo, actionLabel: 'Choisir une vidéo', onPick: () => _importMedia(MediaKind.video), ), const SizedBox(height: 24), Text( 'Mesures disponibles', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), _MeasureSwitch( title: 'Temps', subtitle: 'Chronométrer la durée d’une série.', value: _hasTime, onChanged: (value) => _setMeasure(() => _hasTime = value), ), _MeasureSwitch( title: 'Répétitions', subtitle: 'Compter le nombre de mouvements réalisés.', value: _hasReps, onChanged: (value) => _setMeasure(() => _hasReps = value), ), _MeasureSwitch( title: 'Score', subtitle: 'Saisir une valeur libre avec son unité.', value: _hasScore, onChanged: (value) => _setMeasure(() => _hasScore = value), ), if (_measureError != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text( _measureError!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), if (_hasScore) ...[ const SizedBox(height: 12), Text( 'Mode de saisie', style: Theme.of(context).textTheme.titleSmall, ), RadioListTile( contentPadding: EdgeInsets.zero, title: const Text('Saisie libre'), value: ScoreInputMode.manual, groupValue: _scoreInputMode, onChanged: (value) { if (value == null) return; setState(() => _scoreInputMode = value); }, ), RadioListTile( contentPadding: EdgeInsets.zero, title: const Text('Chrono intégré'), subtitle: const Text('Temps réalisé'), value: ScoreInputMode.stopwatch, groupValue: _scoreInputMode, onChanged: (value) { if (value == null) return; setState(() => _scoreInputMode = value); }, ), if (_scoreInputMode == ScoreInputMode.manual) ...[ const SizedBox(height: 12), TextFormField( controller: _scoreLabelController, decoration: const InputDecoration( labelText: 'Score à saisir', ), validator: (value) { if (!_hasScore || _scoreInputMode != ScoreInputMode.manual) { return null; } return value == null || value.trim().isEmpty ? 'Le libellé du score est obligatoire.' : null; }, ), const SizedBox(height: 12), TextFormField( controller: _scoreUnitController, decoration: const InputDecoration(labelText: 'Unité'), validator: (value) { if (!_hasScore || _scoreInputMode != ScoreInputMode.manual) { return null; } return value == null || value.trim().isEmpty ? 'L’unité du score est obligatoire.' : null; }, ), ], ], if (_warning != null) ...[ const SizedBox(height: 16), MaterialBanner( content: Text(_warning!), leading: const Icon(Icons.info_outline), actions: [ TextButton( onPressed: () => setState(() => _warning = null), child: const Text('OK'), ), ], ), ], const SizedBox(height: 24), FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.check), label: const Text('Enregistrer'), ), ], ), ), ); } void _setMeasure(VoidCallback update) { setState(() { update(); _measureError = null; _warning = null; }); } Future _importMedia(MediaKind kind) async { 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 { final asset = await widget.mediaUseCases.importMedia( sourcePath: sourcePath, kind: kind, ); if (!mounted) return; setState(() { if (kind == MediaKind.image) { _imageMediaId = asset.metadata.id; } else { _videoMediaId = asset.metadata.id; } }); _showSnackBar( kind == MediaKind.image ? 'Image importée.' : 'Vidéo importée.', ); } on Exception catch (error) { _showSnackBar(error.toString()); } finally { if (mounted) { setState(() { _importingImage = false; _importingVideo = false; }); } } } Future _save() async { final hasMeasure = _hasTime || _hasReps || _hasScore; setState(() { _measureError = hasMeasure ? null : 'Active au moins une mesure pour enregistrer.'; }); if (!_formKey.currentState!.validate() || !hasMeasure) { return; } final exercise = widget.exercise; if (exercise != null && _measuresChanged(exercise) && _warning == null && await widget.exerciseUseCases.isUsedInPrograms(exercise.metadata.id)) { if (!mounted) return; setState(() { _warning = 'Cet exercice est déjà utilisé dans un programme. ' 'Les programmes existants restent inchangés.'; }); return; } setState(() => _saving = true); final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual; final scoreLabel = _hasScore ? switch (scoreInputMode) { ScoreInputMode.manual => _scoreLabelController.text.trim(), ScoreInputMode.stopwatch => 'Temps réalisé', } : null; final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual ? _scoreUnitController.text.trim() : null; try { if (exercise == null) { await widget.exerciseUseCases.create( name: _nameController.text.trim(), description: _optionalText(_descriptionController), imageMediaId: _imageMediaId, videoMediaId: _videoMediaId, hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, hasScoreMeasure: _hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, ); } else { await widget.exerciseUseCases.update( id: exercise.metadata.id, name: _nameController.text.trim(), description: _optionalText(_descriptionController), imageMediaId: _imageMediaId, videoMediaId: _videoMediaId, hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, hasScoreMeasure: _hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, ); } if (mounted) { final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.pop(true); } } } on Exception catch (error) { _showSnackBar(error.toString()); } finally { if (mounted) { setState(() => _saving = false); } } } bool _measuresChanged(Exercise exercise) { return exercise.hasTimeMeasure != _hasTime || exercise.hasRepsMeasure != _hasReps || exercise.hasScoreMeasure != _hasScore || exercise.scoreInputMode != _scoreInputMode; } String? _optionalText(TextEditingController controller) { final text = controller.text.trim(); 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( context, ).showSnackBar(SnackBar(content: Text(message))); } } final class MeasureBadge extends StatelessWidget { const MeasureBadge({ required this.measure, this.scoreInputMode = ScoreInputMode.manual, this.scoreUnit, super.key, }); final WorkoutMeasure measure; final ScoreInputMode scoreInputMode; final String? scoreUnit; @override Widget build(BuildContext context) { final unit = scoreUnit?.trim(); var label = measure.label; if (measure == WorkoutMeasure.score && scoreInputMode == ScoreInputMode.stopwatch) { label = 'Score chrono'; } else if (measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty) { label = '${measure.label} ($unit)'; } return Semantics( label: label, excludeSemantics: true, child: Chip(visualDensity: VisualDensity.compact, label: Text(label)), ); } } final class _MeasureSwitch extends StatelessWidget { const _MeasureSwitch({ required this.title, required this.subtitle, required this.value, required this.onChanged, }); final String title; final String subtitle; final bool value; final ValueChanged onChanged; @override Widget build(BuildContext context) { return SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(title), subtitle: Text(subtitle), value: value, onChanged: onChanged, ); } } final class _MediaImportField extends StatelessWidget { const _MediaImportField({ required this.label, required this.selectedFileName, required this.imported, required this.importing, required this.actionLabel, required this.onPick, }); final String label; final String? selectedFileName; final bool imported; final bool importing; final String actionLabel; final VoidCallback onPick; @override Widget build(BuildContext context) { final fileName = selectedFileName; final status = fileName == null ? imported ? 'Média déjà importé' : 'Aucun fichier sélectionné' : fileName; return InputDecorator( decoration: InputDecoration(labelText: label), 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), ), ], ), ); } } final class _CenteredMessage extends StatelessWidget { const _CenteredMessage({ required this.title, this.message, this.actionLabel, this.onAction, }); final String title; final String? message; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), if (message != null) ...[ const SizedBox(height: 8), Text(message!, textAlign: TextAlign.center), ], if (actionLabel != null && onAction != null) ...[ const SizedBox(height: 16), FilledButton(onPressed: onAction, child: Text(actionLabel!)), ], ], ), ), ); } } extension on WorkoutMeasure { String get label { return switch (this) { WorkoutMeasure.time => 'Temps', WorkoutMeasure.reps => 'Répétitions', WorkoutMeasure.score => 'Score', }; } }