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