import 'dart:async'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'theme.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), onDelete: () => _confirmDelete(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(); } } Future _confirmDelete(Exercise exercise) async { final used = await widget.exerciseUseCases.isUsedInPrograms( exercise.metadata.id, ); if (!mounted) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('Supprimer ${exercise.name} ?'), content: Text( used ? 'Cet exercice est utilisé dans un ou plusieurs programmes. ' 'Il sera retiré de la bibliothèque. ' 'Les programmes existants restent inchangés. Continuer ?' : 'Cette action est irréversible.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton.icon( onPressed: () => Navigator.of(context).pop(true), icon: const Icon(Icons.delete_outline), label: const Text('Supprimer'), ), ], ), ); if (confirmed != true) return; try { await widget.exerciseUseCases.delete(exercise.metadata.id); if (!mounted) return; _reload(); } on Exception catch (error) { _showSnackBar(error.toString()); } } void _showSnackBar(String message) { if (!mounted) return; final messenger = ScaffoldMessenger.of(context); messenger.hideCurrentSnackBar(); messenger.showSnackBar(SnackBar(content: Text(message))); } } final class ExerciseListTile extends StatelessWidget { const ExerciseListTile({ required this.exercise, this.onTap, this.onDelete, super.key, }); final Exercise exercise; final VoidCallback? onTap; final VoidCallback? onDelete; @override Widget build(BuildContext context) { final description = exercise.description?.trim(); final iconMediaId = exercise.iconMediaId ?? exercise.imageMediaId; final hasMedia = iconMediaId != null || exercise.videoMediaId != null; return ListTile( onTap: onTap, leading: _ExerciseIconAvatar(iconMediaId: iconMediaId), 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: onDelete == null ? const Icon(Icons.chevron_right) : Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Supprimer l’exercice', icon: const Icon(Icons.delete_outline), onPressed: onDelete, ), const Icon(Icons.chevron_right), ], ), ); } } final class _ExerciseIconAvatar extends StatelessWidget { const _ExerciseIconAvatar({required this.iconMediaId}); final String? iconMediaId; @override Widget build(BuildContext context) { final icon = CircleAvatar( child: Icon( iconMediaId == null ? Icons.fitness_center : Icons.perm_media_outlined, ), ); if (iconMediaId == null) { return icon; } return Tooltip( message: 'Icône d’exercice $iconMediaId', child: Stack( clipBehavior: Clip.none, children: [ icon, Positioned( right: -2, bottom: -2, child: Icon( Icons.star, size: 16, color: Theme.of(context).colorScheme.primary, ), ), ], ), ); } } 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; late final TextEditingController _defaultTimeController; late final TextEditingController _defaultRepsController; late final TextEditingController _defaultScoreController; late final TextEditingController _defaultScoreTimeController; late List _imageMediaIds; String? _iconMediaId; String? _videoMediaId; final _selectedImageNamesById = {}; String? _selectedVideoName; var _hasTime = true; var _hasReps = false; var _hasScore = false; var _scoreInputMode = ScoreInputMode.manual; var _saving = false; var _importingImage = false; var _importingVideo = false; var _stepsEnabled = false; var _autoStartNextTimedStep = true; final _stepDrafts = <_ExerciseStepDraft>[]; String? _measureError; String? _stepError; 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); _defaultTimeController = TextEditingController( text: _optionalIntText(exercise?.defaultTargetTimeSeconds), ); _defaultRepsController = TextEditingController( text: _optionalIntText(exercise?.defaultTargetReps), ); _defaultScoreController = TextEditingController( text: _optionalDoubleText(exercise?.defaultTargetScore), ); _defaultScoreTimeController = TextEditingController( text: _optionalDoubleText( _millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs), ), ); _imageMediaIds = List.of(exercise?.imageMediaIds ?? const []); _iconMediaId = exercise?.iconMediaId; _videoMediaId = exercise?.videoMediaId; _hasTime = exercise?.hasTimeMeasure ?? true; _hasReps = exercise?.hasRepsMeasure ?? false; _hasScore = exercise?.hasScoreMeasure ?? false; _scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual; _stepsEnabled = exercise?.steps.isNotEmpty ?? false; _autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true; for (final step in exercise?.steps ?? const []) { _stepDrafts.add(_draftFromStep(step)); } } @override void dispose() { _nameController.dispose(); _descriptionController.dispose(); _scoreLabelController.dispose(); _scoreUnitController.dispose(); _defaultTimeController.dispose(); _defaultRepsController.dispose(); _defaultScoreController.dispose(); _defaultScoreTimeController.dispose(); for (final draft in _stepDrafts) { draft.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), _ImageGalleryField( imageMediaIds: _imageMediaIds, imageNamesById: _selectedImageNamesById, iconMediaId: _iconMediaId, importing: _importingImage, onAdd: _importImage, onRemove: _removeImage, onSetIcon: _setIcon, ), const SizedBox(height: 8), _MediaImportField( label: 'Vidéo', selectedFileName: _selectedVideoName, imported: _videoMediaId != null, importing: _importingVideo, actionLabel: 'Choisir une vidéo', onPick: _importVideo, ), 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), ), if (_hasTime) ...[ const SizedBox(height: 8), TextFormField( controller: _defaultTimeController, decoration: const InputDecoration( labelText: 'Temps par défaut (s)', ), keyboardType: TextInputType.number, validator: (value) => _hasTime ? _positiveIntValidator( value, 'Saisis un temps supérieur à 0.', ) : null, ), ], _MeasureSwitch( title: 'Répétitions', subtitle: 'Compter le nombre de mouvements réalisés.', value: _hasReps, onChanged: (value) => _setMeasure(() => _hasReps = value), ), if (_hasReps) ...[ const SizedBox(height: 8), TextFormField( controller: _defaultRepsController, decoration: const InputDecoration( labelText: 'Répétitions par défaut', ), keyboardType: TextInputType.number, validator: (value) => _hasReps ? _positiveIntValidator( value, 'Saisis un nombre de répétitions supérieur à 0.', ) : null, ), ], _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; }, ), const SizedBox(height: 12), TextFormField( controller: _defaultScoreController, decoration: const InputDecoration( labelText: 'Score par défaut', ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), validator: (value) { if (!_hasScore || _scoreInputMode != ScoreInputMode.manual) { return null; } return _nonNegativeDoubleValidator( value, 'Saisis un score positif ou nul.', ); }, ), ] else ...[ const SizedBox(height: 12), TextFormField( controller: _defaultScoreTimeController, decoration: const InputDecoration( labelText: 'Objectif de chrono par défaut (optionnel)', ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), validator: (value) { if (!_hasScore || _scoreInputMode != ScoreInputMode.stopwatch || value == null || value.trim().isEmpty) { return null; } return _positiveDoubleValidator( value, 'Saisis un objectif supérieur à 0.', ); }, ), ], ], const SizedBox(height: 24), _buildExerciseStepsSection(context), 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'), ), ], ), ), ); } Widget _buildExerciseStepsSection(BuildContext context) { final reachedLimit = _stepDrafts.length >= 8; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Séquence d’étapes', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Rythmer cet exercice avec des étapes'), subtitle: Text( _stepsEnabled ? 'Chaque passage suit ces étapes dans l’ordre. Si la série ' 'suit des répétitions, une répétition correspond à un ' 'passage complet.' : 'Ajoute des étapes si l’exercice doit suivre un ordre précis ' 'pendant chaque série.', ), value: _stepsEnabled, onChanged: (value) { setState(() { _stepsEnabled = value; _stepError = null; }); }, ), if (_stepsEnabled) ...[ const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text( 'Enchaîner automatiquement les chronos consécutifs', ), subtitle: Text( _autoStartNextTimedStep ? 'Le chrono suivant démarre dès que le précédent arrive à 0.' : "L'app attendra ton démarrage avant de lancer le chrono " 'suivant.', ), value: _autoStartNextTimedStep, onChanged: (value) { setState(() { _autoStartNextTimedStep = value; }); }, ), const SizedBox(height: 8), ReorderableListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, itemCount: _stepDrafts.length, onReorder: _reorderStep, itemBuilder: (context, index) { return _buildStepCard(context, index, _stepDrafts[index]); }, ), if (_stepError != null) ...[ const SizedBox(height: 8), Text( _stepError!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ], const SizedBox(height: 8), if (reachedLimit) const ListTile( contentPadding: EdgeInsets.zero, leading: Icon(Icons.info_outline), title: Text('Limite atteinte'), subtitle: Text('Un exercice peut contenir jusqu’à 8 étapes.'), ) else OutlinedButton.icon( onPressed: _addStep, icon: const Icon(Icons.add), label: const Text('Ajouter une étape'), ), ], ], ); } Widget _buildStepCard( BuildContext context, int index, _ExerciseStepDraft draft, ) { final targetLabel = draft.type == ExerciseStepType.time ? 'Durée par défaut (s)' : 'Répétitions par défaut'; final targetMessage = draft.type == ExerciseStepType.time ? 'Saisis une durée supérieure à 0.' : 'Saisis un nombre de répétitions supérieur à 0.'; final summaryUnit = draft.type == ExerciseStepType.time ? 's' : ''; return CourtBlazerAccentPanel( key: ValueKey(draft.id), margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), child: Material( type: MaterialType.transparency, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ ReorderableDragStartListener( index: index, child: const Padding( padding: EdgeInsets.only(right: 8), child: Icon(Icons.drag_handle), ), ), Expanded( child: Text( '${index + 1}. ${draft.displayName}', style: Theme.of(context).textTheme.titleSmall, ), ), PopupMenuButton<_StepAction>( tooltip: 'Actions de l’étape ${index + 1}', onSelected: (action) => _handleStepAction(index, action), itemBuilder: (context) => [ PopupMenuItem( value: _StepAction.moveUp, enabled: index > 0, child: const Text('Monter'), ), PopupMenuItem( value: _StepAction.moveDown, enabled: index < _stepDrafts.length - 1, child: const Text('Descendre'), ), const PopupMenuItem( value: _StepAction.duplicate, child: Text('Dupliquer'), ), const PopupMenuItem( value: _StepAction.delete, child: Text('Supprimer'), ), ], ), ], ), const SizedBox(height: 8), Text( draft.type == ExerciseStepType.time ? 'Temps · ${draft.targetController.text.trim()} $summaryUnit' : 'Répétitions · ${draft.targetController.text.trim()}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), TextFormField( controller: draft.nameController, decoration: const InputDecoration(labelText: 'Nom de l’étape'), validator: (value) { if (!_stepsEnabled) return null; return value == null || value.trim().isEmpty ? 'Le nom de l’étape est obligatoire.' : null; }, onChanged: (_) => setState(() {}), ), const SizedBox(height: 8), Text('Type d’étape', style: Theme.of(context).textTheme.titleSmall), RadioListTile( contentPadding: EdgeInsets.zero, title: const Text('Temps'), value: ExerciseStepType.time, groupValue: draft.type, onChanged: (value) { if (value == null) return; setState(() => draft.type = value); }, ), RadioListTile( contentPadding: EdgeInsets.zero, title: const Text('Répétitions'), value: ExerciseStepType.reps, groupValue: draft.type, onChanged: (value) { if (value == null) return; setState(() => draft.type = value); }, ), TextFormField( controller: draft.targetController, decoration: InputDecoration(labelText: targetLabel), keyboardType: TextInputType.number, validator: (value) => _stepsEnabled ? _positiveIntValidator(value, targetMessage) : null, onChanged: (_) => setState(() {}), ), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Score d’étape'), subtitle: const Text('Ajouter un score pour cette étape'), value: draft.hasScore, onChanged: (value) => setState(() => draft.hasScore = value), ), if (draft.hasScore) ...[ Text( 'Mode de score', style: Theme.of(context).textTheme.titleSmall, ), RadioListTile( contentPadding: EdgeInsets.zero, title: const Text('Saisie libre'), value: ScoreInputMode.manual, groupValue: draft.scoreInputMode, onChanged: (value) { if (value == null) return; setState(() => draft.scoreInputMode = value); }, ), RadioListTile( contentPadding: EdgeInsets.zero, title: const Text('Chrono intégré'), value: ScoreInputMode.stopwatch, groupValue: draft.scoreInputMode, onChanged: (value) { if (value == null) return; setState(() => draft.scoreInputMode = value); }, ), if (draft.scoreInputMode == ScoreInputMode.manual) ...[ TextFormField( controller: draft.scoreLabelController, decoration: const InputDecoration( labelText: 'Score à saisir', ), validator: (value) { if (!_stepsEnabled || !draft.hasScore) return null; if (draft.scoreInputMode != ScoreInputMode.manual) { return null; } return value == null || value.trim().isEmpty ? 'Le libellé du score est obligatoire.' : null; }, ), const SizedBox(height: 8), TextFormField( controller: draft.scoreUnitController, decoration: const InputDecoration(labelText: 'Unité'), validator: (value) { if (!_stepsEnabled || !draft.hasScore) return null; if (draft.scoreInputMode != ScoreInputMode.manual) { return null; } return value == null || value.trim().isEmpty ? 'L’unité du score est obligatoire.' : null; }, ), const SizedBox(height: 8), TextFormField( controller: draft.scoreTargetController, decoration: const InputDecoration( labelText: 'Score par défaut', ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), validator: (value) { if (!_stepsEnabled || !draft.hasScore || draft.scoreInputMode != ScoreInputMode.manual || value == null || value.trim().isEmpty) { return null; } return _nonNegativeDoubleValidator( value, 'Saisis un score supérieur ou égal à 0.', ); }, ), ] else ...[ if (draft.type == ExerciseStepType.time) ...[ const SizedBox(height: 8), const Text( 'Cette étape utilise déjà un compte à rebours. Le score ' 'chrono ajoute un second temps mesuré ; garde-le seulement ' 'si tu veux enregistrer une performance distincte.', ), ], const SizedBox(height: 8), TextFormField( controller: draft.scoreTargetController, decoration: const InputDecoration( labelText: 'Objectif de chrono par défaut (optionnel)', ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), validator: (value) { if (!_stepsEnabled || !draft.hasScore || draft.scoreInputMode != ScoreInputMode.stopwatch || value == null || value.trim().isEmpty) { return null; } return _positiveDoubleValidator( value, 'Saisis un objectif supérieur à 0.', ); }, ), ], ], ], ), ), ); } void _setMeasure(VoidCallback update) { setState(() { update(); _measureError = null; _warning = null; }); } void _addStep() { if (_stepDrafts.length >= 8) { setState(() { _stepError = 'Un exercice peut contenir jusqu’à 8 étapes.'; }); return; } setState(() { _stepError = null; _stepDrafts.add( _ExerciseStepDraft( id: widget.exerciseUseCases.ids.newId(), name: 'Nouvelle étape', defaultTargetValue: '10', ), ); }); } void _reorderStep(int oldIndex, int newIndex) { setState(() { if (newIndex > oldIndex) { newIndex -= 1; } final draft = _stepDrafts.removeAt(oldIndex); _stepDrafts.insert(newIndex, draft); }); } void _handleStepAction(int index, _StepAction action) { switch (action) { case _StepAction.moveUp: if (index <= 0) return; setState(() { final draft = _stepDrafts.removeAt(index); _stepDrafts.insert(index - 1, draft); }); break; case _StepAction.moveDown: if (index >= _stepDrafts.length - 1) return; setState(() { final draft = _stepDrafts.removeAt(index); _stepDrafts.insert(index + 1, draft); }); break; case _StepAction.duplicate: _duplicateStep(index); break; case _StepAction.delete: unawaited(_confirmRemoveStep(index)); break; } } void _duplicateStep(int index) { if (_stepDrafts.length >= 8) { setState(() { _stepError = 'Un exercice peut contenir jusqu’à 8 étapes.'; }); return; } setState(() { _stepError = null; _stepDrafts.insert( index + 1, _stepDrafts[index].duplicate(widget.exerciseUseCases.ids.newId()), ); }); } Future _confirmRemoveStep(int index) async { final draft = _stepDrafts[index]; var confirmed = true; if (draft.hasFilledContent) { confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Supprimer cette étape ?'), content: const Text('Cette étape sera retirée de la séquence.'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Supprimer'), ), ], ), ) ?? false; } if (!confirmed || !mounted) return; setState(() { final removed = _stepDrafts.removeAt(index); removed.dispose(); _stepError = null; }); } _ExerciseStepDraft _draftFromStep(ExerciseStep step) { return _ExerciseStepDraft( id: step.id, name: step.name, type: step.type, defaultTargetValue: step.defaultTargetValue.toString(), hasScore: step.hasScore, scoreInputMode: step.scoreInputMode, scoreLabel: step.scoreLabel, scoreUnit: step.scoreUnit, scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch ? _optionalDoubleText( _millisecondsToSeconds(step.defaultTargetScoreTimeMs), ) : _optionalDoubleText(step.defaultTargetScore), ); } List _buildSteps() { if (!_stepsEnabled) return const []; return [ for (var index = 0; index < _stepDrafts.length; index++) _stepDrafts[index].toExerciseStep( position: index, defaultTargetScoreTimeMs: _stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch ? _optionalSecondsToMilliseconds( _stepDrafts[index].scoreTargetController.text, ) : null, ), ]; } Future _importImage() async { if (_imageMediaIds.length >= 5) { _showSnackBar('Maximum 5 images par exercice.'); return; } final sourcePath = await widget.mediaPicker.pickPath(MediaKind.image); if (sourcePath == null) { return; } final fileName = _fileNameFromPath(sourcePath); setState(() => _importingImage = true); try { final asset = await widget.mediaUseCases.importMedia( sourcePath: sourcePath, kind: MediaKind.image, ); if (!mounted) return; setState(() { _imageMediaIds = [..._imageMediaIds, asset.metadata.id]; _selectedImageNamesById[asset.metadata.id] = fileName; }); _showSnackBar('Image ajoutée.'); } on Exception catch (error) { _showSnackBar(error.toString()); } finally { if (mounted) { setState(() => _importingImage = false); } } } void _removeImage(String mediaAssetId) { setState(() { _imageMediaIds = _imageMediaIds .where((imageId) => imageId != mediaAssetId) .toList(); if (_iconMediaId == mediaAssetId) { _iconMediaId = null; } _selectedImageNamesById.remove(mediaAssetId); }); } void _setIcon(String mediaAssetId) { setState(() => _iconMediaId = mediaAssetId); } Future _importVideo() async { final sourcePath = await widget.mediaPicker.pickPath(MediaKind.video); if (sourcePath == null) { return; } final fileName = _fileNameFromPath(sourcePath); setState(() { _importingVideo = true; _selectedVideoName = fileName; }); try { final asset = await widget.mediaUseCases.importMedia( sourcePath: sourcePath, kind: MediaKind.video, ); if (!mounted) return; setState(() { _videoMediaId = asset.metadata.id; }); _showSnackBar('Vidéo importée.'); } on Exception catch (error) { _showSnackBar(error.toString()); } finally { if (mounted) { setState(() { _importingVideo = false; }); } } } Future _save() async { final hasMeasure = _hasTime || _hasReps || _hasScore; final hasValidSteps = !_stepsEnabled || _stepDrafts.isNotEmpty; setState(() { _measureError = hasMeasure ? null : 'Active au moins une mesure pour enregistrer.'; _stepError = hasValidSteps ? null : 'Ajoute au moins une étape ou désactive la séquence.'; }); if (!_formKey.currentState!.validate() || !hasMeasure || !hasValidSteps) { 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; final defaultTargetTimeSeconds = _hasTime ? int.parse(_defaultTimeController.text.trim()) : null; final defaultTargetReps = _hasReps ? int.parse(_defaultRepsController.text.trim()) : null; final defaultTargetScore = _hasScore && scoreInputMode == ScoreInputMode.manual ? double.parse(_defaultScoreController.text.trim()) : null; final defaultTargetScoreTimeMs = _hasScore && scoreInputMode == ScoreInputMode.stopwatch ? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text) : null; final steps = _buildSteps(); try { if (exercise == null) { await widget.exerciseUseCases.create( name: _nameController.text.trim(), description: _optionalText(_descriptionController), imageMediaIds: _imageMediaIds, iconMediaId: _iconMediaId, videoMediaId: _videoMediaId, hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, hasScoreMeasure: _hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, defaultTargetTimeSeconds: defaultTargetTimeSeconds, defaultTargetReps: defaultTargetReps, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: _autoStartNextTimedStep, steps: steps, ); } else { await widget.exerciseUseCases.update( id: exercise.metadata.id, name: _nameController.text.trim(), description: _optionalText(_descriptionController), imageMediaIds: _imageMediaIds, iconMediaId: _iconMediaId, videoMediaId: _videoMediaId, hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, hasScoreMeasure: _hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, defaultTargetTimeSeconds: defaultTargetTimeSeconds, defaultTargetReps: defaultTargetReps, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: _autoStartNextTimedStep, steps: steps, ); } 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? _positiveIntValidator(String? value, String message) { final number = int.tryParse(value?.trim() ?? ''); if (number == null || number <= 0) { return message; } return null; } String? _positiveDoubleValidator(String? value, String message) { final number = double.tryParse(value?.trim() ?? ''); if (number == null || number <= 0) { return message; } return null; } String? _nonNegativeDoubleValidator(String? value, String message) { final number = double.tryParse(value?.trim() ?? ''); if (number == null || number < 0) { return message; } return null; } String _optionalIntText(int? value) { return value == null ? '' : '$value'; } String _optionalDoubleText(double? value) { if (value == null) { return ''; } if (value == value.roundToDouble()) { return '${value.round()}'; } return '$value'; } double? _millisecondsToSeconds(int? milliseconds) { return milliseconds == null ? null : milliseconds / 1000; } int? _optionalSecondsToMilliseconds(String value) { final text = value.trim(); if (text.isEmpty) { return null; } return (double.parse(text) * 1000).round(); } 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))); } } enum _StepAction { moveUp, moveDown, duplicate, delete } final class _ExerciseStepDraft { _ExerciseStepDraft({ required this.id, required String name, this.type = ExerciseStepType.time, required String defaultTargetValue, this.hasScore = false, this.scoreInputMode = ScoreInputMode.manual, String? scoreLabel, String? scoreUnit, String? scoreTarget, }) : nameController = TextEditingController(text: name), targetController = TextEditingController(text: defaultTargetValue), scoreLabelController = TextEditingController(text: scoreLabel), scoreUnitController = TextEditingController(text: scoreUnit), scoreTargetController = TextEditingController(text: scoreTarget); final String id; ExerciseStepType type; bool hasScore; ScoreInputMode scoreInputMode; final TextEditingController nameController; final TextEditingController targetController; final TextEditingController scoreLabelController; final TextEditingController scoreUnitController; final TextEditingController scoreTargetController; String get displayName { final text = nameController.text.trim(); return text.isEmpty ? 'Étape sans nom' : text; } bool get hasFilledContent { return nameController.text.trim().isNotEmpty || targetController.text.trim().isNotEmpty || hasScore || scoreLabelController.text.trim().isNotEmpty || scoreUnitController.text.trim().isNotEmpty || scoreTargetController.text.trim().isNotEmpty; } _ExerciseStepDraft duplicate(String id) { return _ExerciseStepDraft( id: id, name: nameController.text, type: type, defaultTargetValue: targetController.text, hasScore: hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabelController.text, scoreUnit: scoreUnitController.text, scoreTarget: scoreTargetController.text, ); } ExerciseStep toExerciseStep({ required int position, required int? defaultTargetScoreTimeMs, }) { final scoreTargetText = scoreTargetController.text.trim(); return ExerciseStep( id: id, position: position, name: nameController.text.trim(), type: type, defaultTargetValue: int.parse(targetController.text.trim()), hasScore: hasScore, scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual, scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual ? scoreLabelController.text.trim() : null, scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual ? scoreUnitController.text.trim() : null, defaultTargetScore: hasScore && scoreInputMode == ScoreInputMode.manual && scoreTargetText.isNotEmpty ? double.parse(scoreTargetText) : null, defaultTargetScoreTimeMs: hasScore && scoreInputMode == ScoreInputMode.stopwatch ? defaultTargetScoreTimeMs : null, ); } void dispose() { nameController.dispose(); targetController.dispose(); scoreLabelController.dispose(); scoreUnitController.dispose(); scoreTargetController.dispose(); } } 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 _ImageGalleryField extends StatelessWidget { const _ImageGalleryField({ required this.imageMediaIds, required this.imageNamesById, required this.iconMediaId, required this.importing, required this.onAdd, required this.onRemove, required this.onSetIcon, }); final List imageMediaIds; final Map imageNamesById; final String? iconMediaId; final bool importing; final VoidCallback onAdd; final ValueChanged onRemove; final ValueChanged onSetIcon; @override Widget build(BuildContext context) { final canAdd = imageMediaIds.length < 5; return InputDecorator( decoration: const InputDecoration(labelText: 'Images'), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (imageMediaIds.isEmpty) const Text('Aucune image sélectionnée') else Wrap( spacing: 8, runSpacing: 8, children: [ for (var index = 0; index < imageMediaIds.length; index++) _ImageThumbnail( mediaAssetId: imageMediaIds[index], selectedAsIcon: imageMediaIds[index] == iconMediaId, label: imageNamesById[imageMediaIds[index]] ?? 'Image ${index + 1}', onRemove: onRemove, onSetIcon: onSetIcon, ), ], ), const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( onPressed: importing ? null : onAdd, icon: importing ? const SizedBox.square( dimension: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.add_photo_alternate_outlined), label: const Text('Ajouter une image'), ), ), if (!canAdd) ...[ const SizedBox(height: 8), Text( 'Maximum 5 images par exercice.', style: Theme.of(context).textTheme.bodySmall, ), ], ], ), ); } } final class _ImageThumbnail extends StatelessWidget { const _ImageThumbnail({ required this.mediaAssetId, required this.selectedAsIcon, required this.label, required this.onRemove, required this.onSetIcon, }); final String mediaAssetId; final bool selectedAsIcon; final String label; final ValueChanged onRemove; final ValueChanged onSetIcon; @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = courtBlazerTokensOf(context); return SizedBox( width: 132, child: Stack( children: [ Container( height: 154, padding: const EdgeInsets.all(8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all( color: selectedAsIcon ? theme.colorScheme.primary : tokens.border, width: selectedAsIcon ? 2 : 1, ), color: theme.colorScheme.surface, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.image_outlined, color: selectedAsIcon ? theme.colorScheme.primary : null, ), const SizedBox(height: 8), Text( label, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: theme.textTheme.labelSmall, ), const SizedBox(height: 8), IconButton( tooltip: 'Définir comme icône', onPressed: selectedAsIcon ? null : () => onSetIcon(mediaAssetId), icon: const Icon(Icons.star_outline, size: 18), ), ], ), ), if (selectedAsIcon) Positioned( top: 4, left: 4, child: Tooltip( message: 'Icône choisie', child: Icon( Icons.star, color: theme.colorScheme.primary, size: 18, ), ), ), Positioned( top: 2, right: 2, child: IconButton.filledTonal( tooltip: 'Supprimer l’image', visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, size: 18), onPressed: () => onRemove(mediaAssetId), ), ), ], ), ); } } 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', }; } }