feat(bibliotheque): tags de filtrage et duplication rapide côté UI (ticket #83)

Lot frontend F1+F2 : composant library_tags.dart (filtre/suggestions de tags), intégration dans exercise_library_screen, program_screen et workout_template_screen (tags + action duplication rapide), tests associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 10:22:47 +02:00
parent e57cfdf54d
commit 1b5fc935bd
7 changed files with 1220 additions and 27 deletions

View File

@ -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<ExerciseLibraryScreen> {
final _searchController = TextEditingController();
var _selectedMeasures = <WorkoutMeasure>{};
var _selectedTags = <String>{};
late Future<List<Exercise>> _exercises;
@override
@ -82,6 +84,10 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
}
final exercises = snapshot.data ?? const <Exercise>[];
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<ExerciseLibraryScreen> {
);
}).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<ExerciseLibraryScreen> {
),
)
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<ExerciseLibraryScreen> {
List<Exercise> _filter(List<Exercise> 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<ExerciseLibraryScreen> {
_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<ExerciseLibraryScreen> {
}
Future<void> _openForm({Exercise? exercise}) async {
final exercises = await _exercises;
if (!mounted) return;
final changed = await Navigator.of(context).push<bool>(
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<String> tagSuggestions;
@override
State<ExerciseFormScreen> createState() => _ExerciseFormScreenState();
@ -430,6 +493,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
var _stepsEnabled = false;
var _autoStartNextTimedStep = true;
final _stepDrafts = <_ExerciseStepDraft>[];
late List<String> _tags;
String? _measureError;
String? _stepError;
String? _warning;
@ -467,6 +531,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
_autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true;
_tags = List<String>.of(exercise?.tags ?? const []);
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
_stepDrafts.add(_draftFromStep(step));
}
@ -515,6 +580,14 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
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<ExerciseFormScreen> {
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep,
steps: steps,
tags: _tags,
);
} else {
await widget.exerciseUseCases.update(
@ -1379,6 +1453,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep,
steps: steps,
tags: _tags,
);
}
if (mounted) {