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. ' 'Le supprimer le retirera de ces programmes. 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; 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); _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; } @override void dispose() { _nameController.dispose(); _descriptionController.dispose(); _scoreLabelController.dispose(); _scoreUnitController.dispose(); _defaultTimeController.dispose(); _defaultRepsController.dispose(); _defaultScoreController.dispose(); _defaultScoreTimeController.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.', ); }, ), ], ], 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 _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; 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; 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; 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, ); } 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, ); } 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))); } } 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', }; } }