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) {
|
||||
|
||||
384
lib/presentation/library_tags.dart
Normal file
384
lib/presentation/library_tags.dart
Normal 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)));
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<String> tags = const [],
|
||||
}) {
|
||||
return WorkoutTemplate(
|
||||
metadata: _metadata('template-1'),
|
||||
name: 'Séance jambes',
|
||||
isExample: isExample,
|
||||
tags: tags,
|
||||
programs: [
|
||||
WorkoutTemplateProgram(
|
||||
metadata: _metadata('template-program-1'),
|
||||
|
||||
Reference in New Issue
Block a user