feat(bibliothèque): badge Exemple, catégories et états vides bibliothèque (ticket #80)

Ajoute le badge "Exemple" (example_badge.dart) affiché sur les
exercices/programmes/séances-modèles seedés, le filtrage par catégorie
dans la bibliothèque d'exercices et les états vides dédiés. Préserve
isExample lors de l'édition d'un programme ou d'une séance-modèle
existants (ProgramUseCases/WorkoutTemplateUseCases).

Validé GO par Main : dart analyze propre, 173 tests (4 échecs
préexistants sans rapport avec ce lot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:39:26 +02:00
parent 24e266c0e9
commit e05405157a
9 changed files with 357 additions and 16 deletions

View File

@ -1306,6 +1306,7 @@ final class ProgramUseCases {
metadata: metadata,
name: name,
defaultRestSeconds: defaultRestSeconds,
isExample: existing?.isExample ?? false,
exercises: programExercises,
);
await programRepository.replaceExercises(program, now);
@ -1551,6 +1552,7 @@ final class WorkoutTemplateUseCases {
metadata: metadata,
name: name,
lastStartedAt: existing?.lastStartedAt,
isExample: existing?.isExample ?? false,
programs: templatePrograms,
overrides: templateOverrides,
);

View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'theme.dart';
final class ExampleBadge extends StatelessWidget {
const ExampleBadge({super.key});
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: tokens.border),
borderRadius: BorderRadius.circular(5),
),
child: Text(
'Exemple',
style: theme.textTheme.labelSmall?.copyWith(color: tokens.mutedText),
),
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:image_picker/image_picker.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'example_badge.dart';
import 'theme.dart';
abstract interface class MediaSourcePicker {
@ -134,9 +135,9 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
SliverFillRemaining(
hasScrollBody: false,
child: _CenteredMessage(
title: 'Aucun exercice pour le moment',
title: 'Aucun exercice',
message:
'Crée ton premier exercice pour composer tes programmes.',
'Crée ton premier exercice ou restaure les exemples.',
actionLabel: 'Créer un exercice',
onAction: () => _openForm(),
),
@ -275,7 +276,15 @@ final class ExerciseListTile extends StatelessWidget {
return ListTile(
onTap: onTap,
leading: _ExerciseIconAvatar(iconMediaId: iconMediaId),
title: Text(exercise.name),
title: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(exercise.name),
if (exercise.isExample) const ExampleBadge(),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -294,6 +303,7 @@ final class ExerciseListTile extends StatelessWidget {
spacing: 6,
runSpacing: 6,
children: [
_CategoryBadge(category: exercise.category),
...exercise.availableMeasures.map(
(measure) => MeasureBadge(
measure: measure,
@ -327,6 +337,20 @@ final class ExerciseListTile extends StatelessWidget {
}
}
final class _CategoryBadge extends StatelessWidget {
const _CategoryBadge({required this.category});
final ExerciseCategory category;
@override
Widget build(BuildContext context) {
return Chip(
visualDensity: VisualDensity.compact,
label: Text(category.label),
);
}
}
final class _ExerciseIconAvatar extends StatelessWidget {
const _ExerciseIconAvatar({required this.iconMediaId});
@ -1786,11 +1810,9 @@ final class _MediaImportField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fileName = selectedFileName;
final status = fileName == null
? imported
? 'Média déjà importé'
: 'Aucun fichier sélectionné'
: fileName;
final status =
fileName ??
(imported ? 'Média déjà importé' : 'Aucun fichier sélectionné');
return InputDecorator(
decoration: InputDecoration(labelText: label),
child: Row(
@ -1867,3 +1889,18 @@ extension on WorkoutMeasure {
};
}
}
extension on ExerciseCategory {
String get label {
return switch (this) {
ExerciseCategory.shoot => 'Tir',
ExerciseCategory.freeThrows => 'Lancers francs',
ExerciseCategory.dribble => 'Dribble',
ExerciseCategory.finishing => 'Finition',
ExerciseCategory.conditioning => 'Condition physique',
ExerciseCategory.defense => 'Défense',
ExerciseCategory.mobility => 'Mobilité',
ExerciseCategory.uncategorized => 'Non catégorisé',
};
}
}

View File

@ -1,4 +1,5 @@
export 'game_time_app.dart';
export 'example_badge.dart';
export 'exercise_step_audio.dart';
export 'exercise_library_screen.dart';
export 'history_screen.dart';

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'example_badge.dart';
import 'exercise_library_screen.dart';
import 'share_screen.dart';
@ -61,9 +62,8 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
final programs = snapshot.data ?? const <Program>[];
if (programs.isEmpty) {
return _CenteredMessage(
title: 'Aucun programme pour le moment',
message:
'Crée un programme pour organiser tes exercices en séries.',
title: 'Aucun programme',
message: 'Assemble quelques exercices pour préparer une séance.',
actionLabel: 'Créer un programme',
onAction: () => _openForm(),
);
@ -74,7 +74,15 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
itemBuilder: (context, index) {
final program = programs[index];
return ListTile(
title: Text(program.name),
title: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(program.name),
if (program.isExample) const ExampleBadge(),
],
),
subtitle: Text(_programSummary(program)),
trailing: Wrap(
spacing: 8,

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'example_badge.dart';
import 'share_screen.dart';
import 'workout_execution_screen.dart';
@ -66,8 +67,9 @@ final class _WorkoutTemplateListScreenState
final templates = snapshot.data ?? const <WorkoutTemplate>[];
if (templates.isEmpty) {
return _CenteredMessage(
title: 'Aucune séance pour le moment',
message: 'Compose une séance à partir de tes programmes.',
title: 'Aucune séance',
message:
'Crée une séance-modèle pour lancer ton entraînement plus vite.',
actionLabel: 'Créer une séance',
onAction: () => _openForm(),
);
@ -78,7 +80,15 @@ final class _WorkoutTemplateListScreenState
itemBuilder: (context, index) {
final template = templates[index];
return ListTile(
title: Text(template.name),
title: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(template.name),
if (template.isExample) const ExampleBadge(),
],
),
subtitle: Text(_templateSummary(template)),
trailing: Wrap(
spacing: 8,