diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 5761a18..1a93454 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'example_badge.dart'; +import 'library_tags.dart'; import 'theme.dart'; abstract interface class MediaSourcePicker { @@ -43,6 +44,7 @@ final class ExerciseLibraryScreen extends StatefulWidget { final class _ExerciseLibraryScreenState extends State { final _searchController = TextEditingController(); var _selectedMeasures = {}; + var _selectedTags = {}; late Future> _exercises; @override @@ -82,6 +84,10 @@ final class _ExerciseLibraryScreenState extends State { } final exercises = snapshot.data ?? const []; + final tagSuggestions = tagSuggestionsFor( + exercises, + (exercise) => exercise.tags, + ).map((usage) => usage.tag).toList(); final filtered = _filter(exercises); return RefreshIndicator( onRefresh: () { @@ -127,6 +133,21 @@ final class _ExerciseLibraryScreenState extends State { ); }).toList(), ), + if (tagSuggestions.isNotEmpty) ...[ + const SizedBox(height: 12), + LibraryTagFilters( + suggestions: tagSuggestions, + selectedTags: _selectedTags, + onToggle: _toggleTag, + ), + ], + if (_filtersActive) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: _clearFilters, + child: const Text('Effacer les filtres'), + ), + ], ], ), ), @@ -143,12 +164,14 @@ final class _ExerciseLibraryScreenState extends State { ), ) else if (filtered.isEmpty) - const SliverFillRemaining( + SliverFillRemaining( hasScrollBody: false, child: _CenteredMessage( title: 'Aucun exercice ne correspond', message: - 'Modifie la recherche ou les filtres de mesures.', + 'Modifie la recherche, les mesures ou les tags sélectionnés.', + actionLabel: 'Effacer les filtres', + onAction: _clearFilters, ), ) else @@ -175,7 +198,7 @@ final class _ExerciseLibraryScreenState extends State { List _filter(List exercises) { final query = _searchController.text.trim().toLowerCase(); - return exercises.where((exercise) { + final matchingQueryAndMeasures = exercises.where((exercise) { final matchesQuery = query.isEmpty || exercise.name.toLowerCase().contains(query) || @@ -185,6 +208,35 @@ final class _ExerciseLibraryScreenState extends State { _selectedMeasures.every(exercise.availableMeasures.contains); return matchesQuery && matchesMeasures; }).toList(); + return filterByRequiredTags( + matchingQueryAndMeasures, + _selectedTags, + (exercise) => exercise.tags, + ); + } + + bool get _filtersActive { + return _searchController.text.trim().isNotEmpty || + _selectedMeasures.isNotEmpty || + _selectedTags.isNotEmpty; + } + + void _toggleTag(String tag) { + setState(() { + if (_selectedTags.contains(tag)) { + _selectedTags = {..._selectedTags}..remove(tag); + } else { + _selectedTags = {..._selectedTags, tag}; + } + }); + } + + void _clearFilters() { + setState(() { + _searchController.clear(); + _selectedMeasures = {}; + _selectedTags = {}; + }); } void _reload() { @@ -194,12 +246,18 @@ final class _ExerciseLibraryScreenState extends State { } Future _openForm({Exercise? exercise}) async { + final exercises = await _exercises; + if (!mounted) return; final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ExerciseFormScreen( exerciseUseCases: widget.exerciseUseCases, mediaUseCases: widget.mediaUseCases, exercise: exercise, + tagSuggestions: tagSuggestionsFor( + exercises, + (exercise) => exercise.tags, + ).map((usage) => usage.tag).toList(), ), ), ); @@ -311,6 +369,9 @@ final class ExerciseListTile extends StatelessWidget { scoreInputMode: exercise.scoreInputMode, ), ), + ...exercise.tags.take(3).map((tag) => TagChip(label: tag)), + if (exercise.tags.length > 3) + TagChip(label: '+${exercise.tags.length - 3}'), if (hasMedia) const Tooltip( message: 'Média associé', @@ -393,6 +454,7 @@ final class ExerciseFormScreen extends StatefulWidget { required this.mediaUseCases, this.mediaPicker = const ImagePickerMediaSourcePicker(), this.exercise, + this.tagSuggestions = const [], super.key, }); @@ -400,6 +462,7 @@ final class ExerciseFormScreen extends StatefulWidget { final MediaUseCases mediaUseCases; final MediaSourcePicker mediaPicker; final Exercise? exercise; + final List tagSuggestions; @override State createState() => _ExerciseFormScreenState(); @@ -430,6 +493,7 @@ final class _ExerciseFormScreenState extends State { var _stepsEnabled = false; var _autoStartNextTimedStep = true; final _stepDrafts = <_ExerciseStepDraft>[]; + late List _tags; String? _measureError; String? _stepError; String? _warning; @@ -467,6 +531,7 @@ final class _ExerciseFormScreenState extends State { _scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual; _stepsEnabled = exercise?.steps.isNotEmpty ?? false; _autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true; + _tags = List.of(exercise?.tags ?? const []); for (final step in exercise?.steps ?? const []) { _stepDrafts.add(_draftFromStep(step)); } @@ -515,6 +580,14 @@ final class _ExerciseFormScreenState extends State { maxLines: 4, ), const SizedBox(height: 24), + EditableTagsSection( + tags: _tags, + suggestions: widget.tagSuggestions, + description: + 'Ajoute quelques mots pour retrouver cet exercice plus vite.', + onChanged: (tags) => setState(() => _tags = tags), + ), + const SizedBox(height: 24), Text( 'Média optionnel', style: Theme.of(context).textTheme.titleMedium, @@ -1358,6 +1431,7 @@ final class _ExerciseFormScreenState extends State { defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: _autoStartNextTimedStep, steps: steps, + tags: _tags, ); } else { await widget.exerciseUseCases.update( @@ -1379,6 +1453,7 @@ final class _ExerciseFormScreenState extends State { defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: _autoStartNextTimedStep, steps: steps, + tags: _tags, ); } if (mounted) { diff --git a/lib/presentation/library_tags.dart b/lib/presentation/library_tags.dart new file mode 100644 index 0000000..358a2a6 --- /dev/null +++ b/lib/presentation/library_tags.dart @@ -0,0 +1,384 @@ +import 'package:flutter/material.dart'; + +import '../domain/domain.dart'; + +final class TagBadgeWrap extends StatelessWidget { + const TagBadgeWrap({required this.tags, this.maxVisible = 3, super.key}); + + final List tags; + final int maxVisible; + + @override + Widget build(BuildContext context) { + if (tags.isEmpty) return const SizedBox.shrink(); + final visible = tags.take(maxVisible).toList(); + final remaining = tags.length - visible.length; + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final tag in visible) TagChip(label: tag), + if (remaining > 0) TagChip(label: '+$remaining'), + ], + ); + } +} + +final class TagChip extends StatelessWidget { + const TagChip({required this.label, this.onDeleted, super.key}); + + final String label; + final VoidCallback? onDeleted; + + @override + Widget build(BuildContext context) { + return Chip( + visualDensity: VisualDensity.compact, + label: Text(label), + onDeleted: onDeleted, + deleteIcon: onDeleted == null ? null : const Icon(Icons.close, size: 16), + ); + } +} + +final class LibraryTagFilters extends StatelessWidget { + const LibraryTagFilters({ + required this.suggestions, + required this.selectedTags, + required this.onToggle, + super.key, + }); + + final List suggestions; + final Set selectedTags; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + if (suggestions.isEmpty) return const SizedBox.shrink(); + final visible = suggestions.take(12).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tags', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in visible) + FilterChip( + label: Text(tag), + selected: selectedTags.contains(tag), + onSelected: (_) => onToggle(tag), + ), + if (suggestions.length > 12) + ActionChip( + label: const Text('Voir tous les tags'), + onPressed: () => showTagFilterSheet( + context: context, + suggestions: suggestions, + selectedTags: selectedTags, + onToggle: onToggle, + ), + ), + ], + ), + ], + ); + } +} + +final class EditableTagsSection extends StatelessWidget { + const EditableTagsSection({ + required this.tags, + required this.suggestions, + required this.onChanged, + this.description, + super.key, + }); + + final List tags; + final List suggestions; + final ValueChanged> onChanged; + final String? description; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tags', style: Theme.of(context).textTheme.titleMedium), + if (description != null) ...[ + const SizedBox(height: 4), + Text(description!, style: Theme.of(context).textTheme.bodySmall), + ], + if (tags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final tag in tags) + TagChip( + label: tag, + onDeleted: () => + onChanged(tags.where((item) => item != tag).toList()), + ), + ], + ), + ], + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _addTag(context), + icon: const Icon(Icons.add), + label: const Text('Ajouter un tag'), + ), + ], + ); + } + + Future _addTag(BuildContext context) async { + if (tags.length >= 8) { + _showSnackBar(context, "Tu peux ajouter jusqu'à 8 tags."); + return; + } + final tag = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => + _AddTagSheet(existingTags: tags, suggestions: suggestions), + ); + if (tag == null) return; + onChanged([...tags, tag]); + } +} + +Future showTagFilterSheet({ + required BuildContext context, + required List suggestions, + required Set selectedTags, + required ValueChanged onToggle, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => _TagFilterSheet( + suggestions: suggestions, + selectedTags: selectedTags, + onToggle: onToggle, + ), + ); +} + +final class _AddTagSheet extends StatefulWidget { + const _AddTagSheet({required this.existingTags, required this.suggestions}); + + final List existingTags; + final List suggestions; + + @override + State<_AddTagSheet> createState() => _AddTagSheetState(); +} + +final class _AddTagSheetState extends State<_AddTagSheet> { + final _controller = TextEditingController(); + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.viewInsetsOf(context).bottom; + final suggestions = widget.suggestions + .where((tag) => !widget.existingTags.contains(tag)) + .take(12) + .toList(); + return Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Ajouter un tag', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Nom du tag', + errorText: _error, + ), + onSubmitted: (_) => _submit(), + ), + if (suggestions.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Tags existants', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in suggestions) + ActionChip( + label: Text(tag), + onPressed: () { + _controller.text = tag; + _submit(); + }, + ), + ], + ), + ], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + const SizedBox(width: 8), + FilledButton(onPressed: _submit, child: const Text('Ajouter')), + ], + ), + ], + ), + ); + } + + void _submit() { + final value = _controller.text; + final normalized = _validateTag(value); + if (normalized == null) return; + Navigator.of(context).pop(normalized); + } + + String? _validateTag(String value) { + try { + final normalized = normalizeTags([value]).singleOrNull; + if (normalized == null) { + setState(() => _error = 'Saisis un tag.'); + return null; + } + if (widget.existingTags.contains(normalized)) { + setState(() => _error = 'Ce tag est déjà ajouté.'); + return null; + } + return normalized; + } on DomainException catch (error) { + setState(() => _error = _tagErrorLabel(error)); + return null; + } + } +} + +final class _TagFilterSheet extends StatefulWidget { + const _TagFilterSheet({ + required this.suggestions, + required this.selectedTags, + required this.onToggle, + }); + + final List suggestions; + final Set selectedTags; + final ValueChanged onToggle; + + @override + State<_TagFilterSheet> createState() => _TagFilterSheetState(); +} + +final class _TagFilterSheetState extends State<_TagFilterSheet> { + final _controller = TextEditingController(); + late Set _selectedTags; + + @override + void initState() { + super.initState(); + _selectedTags = Set.of(widget.selectedTags); + _controller.addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final query = _controller.text.trim().toLowerCase(); + final filtered = widget.suggestions + .where((tag) => query.isEmpty || tag.contains(query)) + .toList(); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tags', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'Rechercher un tag', + prefixIcon: Icon(Icons.search), + ), + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in filtered) + FilterChip( + label: Text(tag), + selected: _selectedTags.contains(tag), + onSelected: (_) { + widget.onToggle(tag); + setState(() { + if (_selectedTags.contains(tag)) { + _selectedTags.remove(tag); + } else { + _selectedTags.add(tag); + } + }); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +String _tagErrorLabel(DomainException error) { + return switch (error.message) { + 'Tag must not be blank.' => 'Saisis un tag.', + 'Tag must be 24 characters or less.' => + 'Un tag contient 24 caractères maximum.', + 'Tags must be unique.' => 'Ce tag est déjà ajouté.', + 'An item cannot have more than 8 tags.' => + "Tu peux ajouter jusqu'à 8 tags.", + _ => error.message, + }; +} + +void _showSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index cbbce72..0304e1d 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -6,6 +6,7 @@ import '../application/application.dart'; import '../domain/domain.dart'; import 'example_badge.dart'; import 'exercise_library_screen.dart'; +import 'library_tags.dart'; import 'share_screen.dart'; final class ProgramListScreen extends StatefulWidget { @@ -29,12 +30,21 @@ final class ProgramListScreen extends StatefulWidget { } final class _ProgramListScreenState extends State { + final _searchController = TextEditingController(); + var _selectedTags = {}; late Future> _programs; @override void initState() { super.initState(); _programs = widget.programUseCases.listActive(); + _searchController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } @override @@ -60,6 +70,10 @@ final class _ProgramListScreenState extends State { ); } final programs = snapshot.data ?? const []; + final tagSuggestions = tagSuggestionsFor( + programs, + (program) => program.tags, + ).map((usage) => usage.tag).toList(); if (programs.isEmpty) { return _CenteredMessage( title: 'Aucun programme', @@ -68,11 +82,30 @@ final class _ProgramListScreenState extends State { onAction: () => _openForm(), ); } + final filtered = _filter(programs); return ListView.separated( - itemCount: programs.length, + itemCount: filtered.isEmpty ? 2 : filtered.length + 1, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { - final program = programs[index]; + if (index == 0) { + return _ProgramFilters( + controller: _searchController, + tagSuggestions: tagSuggestions, + selectedTags: _selectedTags, + filtersActive: _filtersActive, + onToggleTag: _toggleTag, + onClearFilters: _clearFilters, + ); + } + if (filtered.isEmpty) { + return _CenteredMessage( + title: 'Aucun programme ne correspond', + message: 'Modifie la recherche ou les tags sélectionnés.', + actionLabel: 'Effacer les filtres', + onAction: _clearFilters, + ); + } + final program = filtered[index - 1]; return ListTile( title: Wrap( spacing: 8, @@ -83,14 +116,39 @@ final class _ProgramListScreenState extends State { if (program.isExample) const ExampleBadge(), ], ), - subtitle: Text(_programSummary(program)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_programSummary(program)), + if (program.tags.isNotEmpty) ...[ + const SizedBox(height: 6), + TagBadgeWrap(tags: program.tags), + ], + ], + ), trailing: Wrap( spacing: 8, children: [ - IconButton( - tooltip: 'Supprimer le programme', - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmDelete(program), + PopupMenuButton<_ProgramItemAction>( + tooltip: 'Actions', + onSelected: (action) { + switch (action) { + case _ProgramItemAction.duplicate: + unawaited(_duplicate(program)); + case _ProgramItemAction.delete: + unawaited(_confirmDelete(program)); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: _ProgramItemAction.duplicate, + child: Text('Dupliquer'), + ), + PopupMenuItem( + value: _ProgramItemAction.delete, + child: Text('Supprimer'), + ), + ], ), const Icon(Icons.chevron_right), ], @@ -110,7 +168,44 @@ final class _ProgramListScreenState extends State { }); } - Future _openForm({Program? program}) async { + List _filter(List programs) { + final query = _searchController.text.trim().toLowerCase(); + final matchingQuery = programs.where((program) { + return query.isEmpty || + program.name.toLowerCase().contains(query) || + _programSummary(program).toLowerCase().contains(query); + }).toList(); + return filterByRequiredTags( + matchingQuery, + _selectedTags, + (program) => program.tags, + ); + } + + bool get _filtersActive { + return _searchController.text.trim().isNotEmpty || _selectedTags.isNotEmpty; + } + + void _toggleTag(String tag) { + setState(() { + if (_selectedTags.contains(tag)) { + _selectedTags = {..._selectedTags}..remove(tag); + } else { + _selectedTags = {..._selectedTags, tag}; + } + }); + } + + void _clearFilters() { + setState(() { + _searchController.clear(); + _selectedTags = {}; + }); + } + + Future _openForm({Program? program, String? initialSnackBar}) async { + final programs = await _programs; + if (!mounted) return; final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ProgramFormScreen( @@ -120,6 +215,11 @@ final class _ProgramListScreenState extends State { authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, program: program, + tagSuggestions: tagSuggestionsFor( + programs, + (program) => program.tags, + ).map((usage) => usage.tag).toList(), + initialSnackBar: initialSnackBar, ), ), ); @@ -129,6 +229,22 @@ final class _ProgramListScreenState extends State { } } + Future _duplicate(Program program) async { + try { + final copy = await widget.programUseCases.duplicate(program.metadata.id); + if (!mounted) return; + await _openForm( + program: copy, + initialSnackBar: + "Programme dupliqué. Ajuste la copie avant de l'utiliser.", + ); + if (!mounted) return; + _reload(); + } on Exception { + _showSnackBar('Duplication impossible pour le moment.'); + } + } + Future _confirmDelete(Program program) async { final used = await widget.programUseCases.isUsedInWorkoutTemplates( program.metadata.id, @@ -175,6 +291,63 @@ final class _ProgramListScreenState extends State { } } +enum _ProgramItemAction { duplicate, delete } + +final class _ProgramFilters extends StatelessWidget { + const _ProgramFilters({ + required this.controller, + required this.tagSuggestions, + required this.selectedTags, + required this.filtersActive, + required this.onToggleTag, + required this.onClearFilters, + }); + + final TextEditingController controller; + final List tagSuggestions; + final Set selectedTags; + final bool filtersActive; + final ValueChanged onToggleTag; + final VoidCallback onClearFilters; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Rechercher', + prefixIcon: Icon(Icons.search), + ), + ), + if (tagSuggestions.isNotEmpty) ...[ + const SizedBox(height: 12), + LibraryTagFilters( + suggestions: tagSuggestions, + selectedTags: selectedTags, + onToggle: onToggleTag, + ), + ], + if (filtersActive) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: onClearFilters, + child: const Text('Effacer les filtres'), + ), + ), + ], + ], + ), + ); + } +} + final class ProgramFormScreen extends StatefulWidget { const ProgramFormScreen({ required this.programUseCases, @@ -183,6 +356,8 @@ final class ProgramFormScreen extends StatefulWidget { this.authUseCases, this.syncUseCases, this.program, + this.tagSuggestions = const [], + this.initialSnackBar, super.key, }); @@ -192,6 +367,8 @@ final class ProgramFormScreen extends StatefulWidget { final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; final Program? program; + final List tagSuggestions; + final String? initialSnackBar; @override State createState() => _ProgramFormScreenState(); @@ -202,6 +379,7 @@ final class _ProgramFormScreenState extends State { late final TextEditingController _nameController; late final TextEditingController _defaultRestController; late final List<_ProgramExerciseDraft> _exercises; + late List _tags; Timer? _addedSnackBarTimer; var _saving = false; @@ -223,6 +401,13 @@ final class _ProgramFormScreenState extends State { for (final exercise in program?.exercises ?? const []) _ProgramExerciseDraft.fromProgramExercise(exercise), ]; + _tags = List.of(program?.tags ?? const []); + final initialSnackBar = widget.initialSnackBar; + if (initialSnackBar != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showSnackBar(initialSnackBar); + }); + } } @override @@ -288,6 +473,12 @@ final class _ProgramFormScreenState extends State { onChanged: (_) => setState(() {}), ), const SizedBox(height: 12), + EditableTagsSection( + tags: _tags, + suggestions: widget.tagSuggestions, + onChanged: (tags) => setState(() => _tags = tags), + ), + const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -413,6 +604,7 @@ final class _ProgramFormScreenState extends State { id: widget.program?.metadata.id, name: _nameController.text.trim(), defaultRestSeconds: _defaultRestSeconds, + tags: _tags, exercises: _exercises.map((exercise) => exercise.toConfig()).toList(), ); if (mounted) { diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index b1e2453..33c627b 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -5,6 +6,7 @@ import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'example_badge.dart'; +import 'library_tags.dart'; import 'share_screen.dart'; import 'workout_execution_screen.dart'; @@ -43,12 +45,21 @@ final class WorkoutTemplateListScreen extends StatefulWidget { final class _WorkoutTemplateListScreenState extends State { + final _searchController = TextEditingController(); + var _selectedTags = {}; late Future> _templates; @override void initState() { super.initState(); _templates = widget.workoutTemplateUseCases.listActive(); + _searchController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } @override @@ -67,6 +78,10 @@ final class _WorkoutTemplateListScreenState return const Center(child: CircularProgressIndicator()); } final templates = snapshot.data ?? const []; + final tagSuggestions = tagSuggestionsFor( + templates, + (template) => template.tags, + ).map((usage) => usage.tag).toList(); if (templates.isEmpty) { return _CenteredMessage( title: 'Aucune séance', @@ -76,11 +91,30 @@ final class _WorkoutTemplateListScreenState onAction: () => _openForm(), ); } + final filtered = _filter(templates); return ListView.separated( - itemCount: templates.length, + itemCount: filtered.isEmpty ? 2 : filtered.length + 1, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { - final template = templates[index]; + if (index == 0) { + return _WorkoutTemplateFilters( + controller: _searchController, + tagSuggestions: tagSuggestions, + selectedTags: _selectedTags, + filtersActive: _filtersActive, + onToggleTag: _toggleTag, + onClearFilters: _clearFilters, + ); + } + if (filtered.isEmpty) { + return _CenteredMessage( + title: 'Aucune séance ne correspond', + message: 'Modifie la recherche ou les tags sélectionnés.', + actionLabel: 'Effacer les filtres', + onAction: _clearFilters, + ); + } + final template = filtered[index - 1]; return ListTile( title: Wrap( spacing: 8, @@ -91,19 +125,44 @@ final class _WorkoutTemplateListScreenState if (template.isExample) const ExampleBadge(), ], ), - subtitle: Text(_templateSummary(template)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_templateSummary(template)), + if (template.tags.isNotEmpty) ...[ + const SizedBox(height: 6), + TagBadgeWrap(tags: template.tags), + ], + ], + ), trailing: Wrap( spacing: 8, children: [ - IconButton( - tooltip: 'Supprimer la séance', - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmDelete(template), - ), TextButton( onPressed: () => _start(template), child: const Text('Lancer'), ), + PopupMenuButton<_WorkoutTemplateItemAction>( + tooltip: 'Actions', + onSelected: (action) { + switch (action) { + case _WorkoutTemplateItemAction.duplicate: + unawaited(_duplicate(template)); + case _WorkoutTemplateItemAction.delete: + unawaited(_confirmDelete(template)); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: _WorkoutTemplateItemAction.duplicate, + child: Text('Dupliquer'), + ), + PopupMenuItem( + value: _WorkoutTemplateItemAction.delete, + child: Text('Supprimer'), + ), + ], + ), const Icon(Icons.chevron_right), ], ), @@ -122,7 +181,47 @@ final class _WorkoutTemplateListScreenState }); } - Future _openForm({WorkoutTemplate? template}) async { + List _filter(List templates) { + final query = _searchController.text.trim().toLowerCase(); + final matchingQuery = templates.where((template) { + return query.isEmpty || + template.name.toLowerCase().contains(query) || + _templateSummary(template).toLowerCase().contains(query); + }).toList(); + return filterByRequiredTags( + matchingQuery, + _selectedTags, + (template) => template.tags, + ); + } + + bool get _filtersActive { + return _searchController.text.trim().isNotEmpty || _selectedTags.isNotEmpty; + } + + void _toggleTag(String tag) { + setState(() { + if (_selectedTags.contains(tag)) { + _selectedTags = {..._selectedTags}..remove(tag); + } else { + _selectedTags = {..._selectedTags, tag}; + } + }); + } + + void _clearFilters() { + setState(() { + _searchController.clear(); + _selectedTags = {}; + }); + } + + Future _openForm({ + WorkoutTemplate? template, + String? initialSnackBar, + }) async { + final templates = await _templates; + if (!mounted) return; final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => WorkoutTemplateFormScreen( @@ -132,6 +231,11 @@ final class _WorkoutTemplateListScreenState authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, template: template, + tagSuggestions: tagSuggestionsFor( + templates, + (template) => template.tags, + ).map((usage) => usage.tag).toList(), + initialSnackBar: initialSnackBar, ), ), ); @@ -141,6 +245,24 @@ final class _WorkoutTemplateListScreenState } } + Future _duplicate(WorkoutTemplate template) async { + try { + final copy = await widget.workoutTemplateUseCases.duplicate( + template.metadata.id, + ); + if (!mounted) return; + await _openForm( + template: copy, + initialSnackBar: + 'Séance dupliquée. Ajuste la copie avant de la lancer.', + ); + if (!mounted) return; + _reload(); + } on Exception { + _showSnackBar('Duplication impossible pour le moment.'); + } + } + Future _start(WorkoutTemplate template) async { final openSession = await widget.activeUseCases.findOpen(); if (!mounted) return; @@ -212,6 +334,63 @@ final class _WorkoutTemplateListScreenState } } +enum _WorkoutTemplateItemAction { duplicate, delete } + +final class _WorkoutTemplateFilters extends StatelessWidget { + const _WorkoutTemplateFilters({ + required this.controller, + required this.tagSuggestions, + required this.selectedTags, + required this.filtersActive, + required this.onToggleTag, + required this.onClearFilters, + }); + + final TextEditingController controller; + final List tagSuggestions; + final Set selectedTags; + final bool filtersActive; + final ValueChanged onToggleTag; + final VoidCallback onClearFilters; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Rechercher', + prefixIcon: Icon(Icons.search), + ), + ), + if (tagSuggestions.isNotEmpty) ...[ + const SizedBox(height: 12), + LibraryTagFilters( + suggestions: tagSuggestions, + selectedTags: selectedTags, + onToggle: onToggleTag, + ), + ], + if (filtersActive) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: onClearFilters, + child: const Text('Effacer les filtres'), + ), + ), + ], + ], + ), + ); + } +} + final class WorkoutTemplateFormScreen extends StatefulWidget { const WorkoutTemplateFormScreen({ required this.workoutTemplateUseCases, @@ -220,6 +399,8 @@ final class WorkoutTemplateFormScreen extends StatefulWidget { this.authUseCases, this.syncUseCases, this.template, + this.tagSuggestions = const [], + this.initialSnackBar, super.key, }); @@ -229,6 +410,8 @@ final class WorkoutTemplateFormScreen extends StatefulWidget { final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; final WorkoutTemplate? template; + final List tagSuggestions; + final String? initialSnackBar; @override State createState() => @@ -240,6 +423,7 @@ final class _WorkoutTemplateFormScreenState final _formKey = GlobalKey(); late final TextEditingController _nameController; late final List _programs; + late List _tags; var _saving = false; bool get _isEditing => widget.template != null; @@ -257,6 +441,13 @@ final class _WorkoutTemplateFormScreenState template?.overrides ?? const [], ), ]; + _tags = List.of(template?.tags ?? const []); + final initialSnackBar = widget.initialSnackBar; + if (initialSnackBar != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showSnackBar(initialSnackBar); + }); + } } @override @@ -308,6 +499,12 @@ final class _WorkoutTemplateFormScreenState : null, ), const SizedBox(height: 12), + EditableTagsSection( + tags: _tags, + suggestions: widget.tagSuggestions, + onChanged: (tags) => setState(() => _tags = tags), + ), + const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -410,6 +607,7 @@ final class _WorkoutTemplateFormScreenState await widget.workoutTemplateUseCases.saveConfigured( id: widget.template?.metadata.id, name: _nameController.text.trim(), + tags: _tags, programs: _programs.map((program) => program.toConfig()).toList(), overrides: _programs .expand((program) => program.toOverrides()) diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index e433036..f7163e2 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -21,6 +21,12 @@ void main() { ); await tester.enterText(find.byType(TextFormField).first, 'Gainage'); + await tester.dragUntilVisible( + find.widgetWithText(SwitchListTile, 'Temps'), + find.byType(ListView), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(SwitchListTile, 'Temps')); await tester.pump(); await tester.dragUntilVisible( @@ -73,6 +79,103 @@ void main() { expect(find.text('Exemple'), findsOneWidget); }); + testWidgets('ajouter un tag à un exercice le sauvegarde normalisé', ( + tester, + ) async { + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ExerciseFormScreen( + exerciseUseCases: _exerciseUseCases(exerciseRepository), + mediaUseCases: _mediaUseCases(exerciseRepository), + tagSuggestions: const ['match'], + ), + ), + ); + + await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs'); + await tester.tap(find.text('Ajouter un tag')); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'Nom du tag'), + ' Match ', + ); + await tester.tap(find.widgetWithText(FilledButton, 'Ajouter')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(Chip, 'match'), findsOneWidget); + + await tester.dragUntilVisible( + find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + find.byType(ListView), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + '30', + ); + await tester.dragUntilVisible( + find.text('Enregistrer'), + find.byType(ListView), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Enregistrer')); + await tester.pumpAndSettle(); + + expect(exerciseRepository.saved.single.tags, ['match']); + }); + + testWidgets('filtre les exercices par tag et efface tous les filtres', ( + tester, + ) async { + final exerciseRepository = _FakeExerciseRepository() + ..exercises.addAll([ + Exercise( + metadata: _metadata('exercise-1'), + name: 'Tirs match', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + tags: const ['match', 'extérieur'], + ), + Exercise( + metadata: _metadata('exercise-2'), + name: 'Dribble routine', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + tags: const ['routine'], + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: ExerciseLibraryScreen( + exerciseUseCases: _exerciseUseCases(exerciseRepository), + mediaUseCases: _mediaUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.widgetWithText(FilterChip, 'match')); + await tester.pump(); + + expect(find.text('Tirs match'), findsOneWidget); + expect(find.text('Dribble routine'), findsNothing); + expect(find.text('Effacer les filtres'), findsOneWidget); + + await tester.tap(find.text('Effacer les filtres')); + await tester.pump(); + + expect(find.text('Tirs match'), findsOneWidget); + expect(find.text('Dribble routine'), findsOneWidget); + }); + testWidgets('basculer en chrono intégré masque l’unité et affiche le badge', ( tester, ) async { diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index e0941ed..d597d9a 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -35,7 +35,7 @@ void main() { expect(find.text('Fondations basket - 45 min'), findsOneWidget); expect(find.text('Exemple'), findsOneWidget); - expect(find.byTooltip('Supprimer le programme'), findsOneWidget); + expect(find.byTooltip('Actions'), findsOneWidget); }); testWidgets('la liste programmes vide conserve le message dédié', ( @@ -65,6 +65,98 @@ void main() { ); }); + testWidgets('filtre les programmes par tag et peut effacer les filtres', ( + tester, + ) async { + final programRepository = _FakeProgramRepository() + ..programs.addAll([ + Program( + metadata: _metadata('program-1'), + name: 'Programme match', + defaultRestSeconds: 45, + tags: const ['match', 'intense'], + ), + Program( + metadata: _metadata('program-2'), + name: 'Programme routine', + defaultRestSeconds: 30, + tags: const ['routine'], + ), + ]); + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ProgramListScreen( + programUseCases: _programUseCases( + programRepository, + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.widgetWithText(FilterChip, 'match'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilterChip, 'match')); + await tester.pump(); + + expect(find.text('Programme match'), findsOneWidget); + expect(find.text('Programme routine'), findsNothing); + expect(find.text('Effacer les filtres'), findsOneWidget); + + await tester.tap(find.text('Effacer les filtres')); + await tester.pump(); + + expect(find.text('Programme match'), findsOneWidget); + expect(find.text('Programme routine'), findsOneWidget); + }); + + testWidgets('dupliquer un programme ouvre la copie en édition', ( + tester, + ) async { + final programRepository = _FakeProgramRepository() + ..programs.add( + Program( + metadata: _metadata('program-1'), + name: 'Jambes', + defaultRestSeconds: 60, + tags: const ['match'], + ), + ); + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ProgramListScreen( + programUseCases: _programUseCases( + programRepository, + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byTooltip('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dupliquer')); + await tester.pumpAndSettle(); + + expect(find.text('Modifier le programme'), findsOneWidget); + expect(find.text('Copie de Jambes'), findsOneWidget); + expect( + find.text("Programme dupliqué. Ajuste la copie avant de l'utiliser."), + findsOneWidget, + ); + expect(find.widgetWithText(Chip, 'match'), findsOneWidget); + }); + testWidgets('modifier un programme exemple conserve sa provenance', ( tester, ) async { @@ -676,7 +768,9 @@ void main() { await tester.pump(); await tester.pump(); - await tester.tap(find.byTooltip('Supprimer le programme')); + await tester.tap(find.byTooltip('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Supprimer')); await tester.pumpAndSettle(); expect(find.text('Supprimer Jambes ?'), findsOneWidget); @@ -691,7 +785,9 @@ void main() { expect(find.text('Jambes'), findsOneWidget); expect(programRepository.saved, isEmpty); - await tester.tap(find.byTooltip('Supprimer le programme')); + await tester.tap(find.byTooltip('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Supprimer')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); await tester.pumpAndSettle(); diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index c9ecc4d..1941331 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -62,7 +62,7 @@ void main() { expect(find.text('Séance jambes'), findsOneWidget); expect(find.text('Exemple'), findsOneWidget); - expect(find.byTooltip('Supprimer la séance'), findsOneWidget); + expect(find.byTooltip('Actions'), findsOneWidget); expect(find.text('Lancer'), findsOneWidget); }); @@ -126,6 +126,143 @@ void main() { ); }); + testWidgets('filtre les séances par tag et peut effacer les filtres', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository([ + _workoutTemplate(tags: const ['match', 'intense']), + WorkoutTemplate( + metadata: _metadata('template-2'), + name: 'Séance routine', + tags: const ['routine'], + ), + ]); + final activeRepository = _FakeActiveSessionRepository(); + final programRepository = _FakeProgramRepository(); + final historyRepository = _FakeWorkoutHistoryRepository(); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutTemplateListScreen( + workoutTemplateUseCases: WorkoutTemplateUseCases( + templateRepository: templateRepository, + programRepository: programRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + programUseCases: ProgramUseCases( + programRepository: programRepository, + exerciseRepository: _FakeExerciseRepository(), + templateRepository: templateRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + activeUseCases: ActiveWorkoutSessionUseCases( + sessionRepository: activeRepository, + templateRepository: templateRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + closeUseCase: CloseWorkoutSessionUseCase( + sessionRepository: activeRepository, + historyRepository: historyRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + historyUseCases: WorkoutHistoryUseCases( + repository: historyRepository, + clock: clock, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.widgetWithText(FilterChip, 'match')); + await tester.pump(); + + expect(find.text('Séance jambes'), findsOneWidget); + expect(find.text('Séance routine'), findsNothing); + expect(find.text('Effacer les filtres'), findsOneWidget); + + await tester.tap(find.text('Effacer les filtres')); + await tester.pump(); + + expect(find.text('Séance jambes'), findsOneWidget); + expect(find.text('Séance routine'), findsOneWidget); + }); + + testWidgets('dupliquer une séance ouvre la copie en édition', (tester) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository([ + _workoutTemplate(tags: const ['match']), + ]); + final activeRepository = _FakeActiveSessionRepository(); + final programRepository = _FakeProgramRepository(); + final historyRepository = _FakeWorkoutHistoryRepository(); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutTemplateListScreen( + workoutTemplateUseCases: WorkoutTemplateUseCases( + templateRepository: templateRepository, + programRepository: programRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + programUseCases: ProgramUseCases( + programRepository: programRepository, + exerciseRepository: _FakeExerciseRepository(), + templateRepository: templateRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + activeUseCases: ActiveWorkoutSessionUseCases( + sessionRepository: activeRepository, + templateRepository: templateRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + closeUseCase: CloseWorkoutSessionUseCase( + sessionRepository: activeRepository, + historyRepository: historyRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + historyUseCases: WorkoutHistoryUseCases( + repository: historyRepository, + clock: clock, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byTooltip('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dupliquer')); + await tester.pumpAndSettle(); + + expect(find.text('Modifier la séance'), findsOneWidget); + expect(find.text('Copie de Séance jambes'), findsOneWidget); + expect( + find.text('Séance dupliquée. Ajuste la copie avant de la lancer.'), + findsOneWidget, + ); + expect(find.widgetWithText(Chip, 'match'), findsOneWidget); + }); + testWidgets('modifier une séance exemple conserve sa provenance', ( tester, ) async { @@ -433,7 +570,9 @@ void main() { await tester.pump(); await tester.pump(); - await tester.tap(find.byTooltip('Supprimer la séance')); + await tester.tap(find.byTooltip('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Supprimer')); await tester.pumpAndSettle(); expect(find.text('Supprimer Séance jambes ?'), findsOneWidget); @@ -445,7 +584,9 @@ void main() { expect(find.text('Séance jambes'), findsOneWidget); expect(templateRepository.saved, isEmpty); - await tester.tap(find.byTooltip('Supprimer la séance')); + await tester.tap(find.byTooltip('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Supprimer')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); await tester.pumpAndSettle(); @@ -511,11 +652,15 @@ void main() { }); } -WorkoutTemplate _workoutTemplate({bool isExample = false}) { +WorkoutTemplate _workoutTemplate({ + bool isExample = false, + List tags = const [], +}) { return WorkoutTemplate( metadata: _metadata('template-1'), name: 'Séance jambes', isExample: isExample, + tags: tags, programs: [ WorkoutTemplateProgram( metadata: _metadata('template-program-1'),