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:
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user