import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; 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), border: OutlineInputBorder(), ), ), 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, ), ), 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.exercise, super.key, }); final ExerciseUseCases exerciseUseCases; final MediaUseCases mediaUseCases; 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; final _imagePathController = TextEditingController(); final _videoPathController = TextEditingController(); String? _imageMediaId; String? _videoMediaId; var _hasTime = true; var _hasReps = false; var _hasScore = false; 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; } @override void dispose() { _nameController.dispose(); _descriptionController.dispose(); _scoreLabelController.dispose(); _scoreUnitController.dispose(); _imagePathController.dispose(); _videoPathController.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', border: OutlineInputBorder(), ), 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', border: OutlineInputBorder(), ), minLines: 2, maxLines: 4, ), const SizedBox(height: 24), Text( 'Média optionnel', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), _MediaImportField( controller: _imagePathController, label: 'Chemin de l’image', imported: _imageMediaId != null, importing: _importingImage, onImport: () => _importMedia(MediaKind.image), ), const SizedBox(height: 8), _MediaImportField( controller: _videoPathController, label: 'Chemin de la vidéo', imported: _videoMediaId != null, importing: _importingVideo, onImport: () => _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), TextFormField( controller: _scoreLabelController, decoration: const InputDecoration( labelText: 'Score à saisir', border: OutlineInputBorder(), ), validator: (value) { if (!_hasScore) 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é', border: OutlineInputBorder(), ), validator: (value) { if (!_hasScore) 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 controller = kind == MediaKind.image ? _imagePathController : _videoPathController; final sourcePath = controller.text.trim(); if (sourcePath.isEmpty) { _showSnackBar('Indique un chemin de fichier à importer.'); return; } setState(() { if (kind == MediaKind.image) { _importingImage = true; } else { _importingVideo = true; } }); 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); 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, scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null, scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null, ); } 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, scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null, scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null, ); } if (mounted) { Navigator.of(context).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; } String? _optionalText(TextEditingController controller) { final text = controller.text.trim(); return text.isEmpty ? null : text; } 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.scoreUnit, super.key}); final WorkoutMeasure measure; final String? scoreUnit; @override Widget build(BuildContext context) { final unit = scoreUnit?.trim(); final label = measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty ? '${measure.label} ($unit)' : measure.label; return Semantics( label: label, 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.controller, required this.label, required this.imported, required this.importing, required this.onImport, }); final TextEditingController controller; final String label; final bool imported; final bool importing; final VoidCallback onImport; @override Widget build(BuildContext context) { return TextField( controller: controller, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (imported) const Tooltip( message: 'Média importé', child: Icon(Icons.check_circle_outline), ), IconButton( tooltip: 'Importer', onPressed: importing ? null : onImport, icon: importing ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.upload_file), ), ], ), ), ); } } 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', }; } }