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) {

View File

@ -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<String> 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<String> suggestions;
final Set<String> selectedTags;
final ValueChanged<String> 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<String> tags;
final List<String> suggestions;
final ValueChanged<List<String>> 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<void> _addTag(BuildContext context) async {
if (tags.length >= 8) {
_showSnackBar(context, "Tu peux ajouter jusqu'à 8 tags.");
return;
}
final tag = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) =>
_AddTagSheet(existingTags: tags, suggestions: suggestions),
);
if (tag == null) return;
onChanged([...tags, tag]);
}
}
Future<void> showTagFilterSheet({
required BuildContext context,
required List<String> suggestions,
required Set<String> selectedTags,
required ValueChanged<String> onToggle,
}) {
return showModalBottomSheet<void>(
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<String> existingTags;
final List<String> 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<String> suggestions;
final Set<String> selectedTags;
final ValueChanged<String> onToggle;
@override
State<_TagFilterSheet> createState() => _TagFilterSheetState();
}
final class _TagFilterSheetState extends State<_TagFilterSheet> {
final _controller = TextEditingController();
late Set<String> _selectedTags;
@override
void initState() {
super.initState();
_selectedTags = Set<String>.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)));
}

View File

@ -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<ProgramListScreen> {
final _searchController = TextEditingController();
var _selectedTags = <String>{};
late Future<List<Program>> _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<ProgramListScreen> {
);
}
final programs = snapshot.data ?? const <Program>[];
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<ProgramListScreen> {
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<ProgramListScreen> {
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<ProgramListScreen> {
});
}
Future<void> _openForm({Program? program}) async {
List<Program> _filter(List<Program> 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<void> _openForm({Program? program, String? initialSnackBar}) async {
final programs = await _programs;
if (!mounted) return;
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => ProgramFormScreen(
@ -120,6 +215,11 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
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<ProgramListScreen> {
}
}
Future<void> _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<void> _confirmDelete(Program program) async {
final used = await widget.programUseCases.isUsedInWorkoutTemplates(
program.metadata.id,
@ -175,6 +291,63 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
}
}
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<String> tagSuggestions;
final Set<String> selectedTags;
final bool filtersActive;
final ValueChanged<String> 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<String> tagSuggestions;
final String? initialSnackBar;
@override
State<ProgramFormScreen> createState() => _ProgramFormScreenState();
@ -202,6 +379,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
late final TextEditingController _nameController;
late final TextEditingController _defaultRestController;
late final List<_ProgramExerciseDraft> _exercises;
late List<String> _tags;
Timer? _addedSnackBarTimer;
var _saving = false;
@ -223,6 +401,13 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
for (final exercise in program?.exercises ?? const <ProgramExercise>[])
_ProgramExerciseDraft.fromProgramExercise(exercise),
];
_tags = List<String>.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<ProgramFormScreen> {
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<ProgramFormScreen> {
id: widget.program?.metadata.id,
name: _nameController.text.trim(),
defaultRestSeconds: _defaultRestSeconds,
tags: _tags,
exercises: _exercises.map((exercise) => exercise.toConfig()).toList(),
);
if (mounted) {

View File

@ -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<WorkoutTemplateListScreen> {
final _searchController = TextEditingController();
var _selectedTags = <String>{};
late Future<List<WorkoutTemplate>> _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 <WorkoutTemplate>[];
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<void> _openForm({WorkoutTemplate? template}) async {
List<WorkoutTemplate> _filter(List<WorkoutTemplate> 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<void> _openForm({
WorkoutTemplate? template,
String? initialSnackBar,
}) async {
final templates = await _templates;
if (!mounted) return;
final changed = await Navigator.of(context).push<bool>(
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<void> _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<void> _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<String> tagSuggestions;
final Set<String> selectedTags;
final bool filtersActive;
final ValueChanged<String> 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<String> tagSuggestions;
final String? initialSnackBar;
@override
State<WorkoutTemplateFormScreen> createState() =>
@ -240,6 +423,7 @@ final class _WorkoutTemplateFormScreenState
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final List<WorkoutTemplateProgramDraft> _programs;
late List<String> _tags;
var _saving = false;
bool get _isEditing => widget.template != null;
@ -257,6 +441,13 @@ final class _WorkoutTemplateFormScreenState
template?.overrides ?? const <WorkoutTemplateExerciseOverride>[],
),
];
_tags = List<String>.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())