From e05405157ac08a0293564289389077ece088cc98 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 07:39:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(biblioth=C3=A8que):=20badge=20Exemple,=20c?= =?UTF-8?q?at=C3=A9gories=20et=20=C3=A9tats=20vides=20biblioth=C3=A8que=20?= =?UTF-8?q?(ticket=20#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/application/use_cases.dart | 2 + lib/presentation/example_badge.dart | 24 +++ lib/presentation/exercise_library_screen.dart | 53 +++++- lib/presentation/presentation.dart | 1 + lib/presentation/program_screen.dart | 16 +- lib/presentation/workout_template_screen.dart | 16 +- .../exercise_library_screen_test.dart | 4 + test/presentation/program_screen_test.dart | 95 ++++++++++ .../workout_template_screen_test.dart | 162 +++++++++++++++++- 9 files changed, 357 insertions(+), 16 deletions(-) create mode 100644 lib/presentation/example_badge.dart diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 7692779..322486f 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -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, ); diff --git a/lib/presentation/example_badge.dart b/lib/presentation/example_badge.dart new file mode 100644 index 0000000..d79b94f --- /dev/null +++ b/lib/presentation/example_badge.dart @@ -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), + ), + ); + } +} diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 70fddc9..5761a18 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -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 { 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é', + }; + } +} diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index c3c32da..e563c69 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -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'; diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index a9bf91b..cbbce72 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -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 { final programs = snapshot.data ?? const []; 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 { 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, diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index aec5123..f28cf1d 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -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 []; 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, diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index 0613548..e433036 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -54,6 +54,8 @@ void main() { defaultTargetTimeSeconds: 45, defaultTargetReps: 10, defaultTargetScore: 80, + category: ExerciseCategory.finishing, + isExample: true, ); await tester.pumpWidget( @@ -67,6 +69,8 @@ void main() { expect(find.text('Temps'), findsOneWidget); expect(find.text('Répétitions'), findsOneWidget); expect(find.text('Score (kg)'), findsOneWidget); + expect(find.text('Finition'), findsOneWidget); + expect(find.text('Exemple'), findsOneWidget); }); testWidgets('basculer en chrono intégré masque l’unité et affiche le badge', ( diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 7f5c390..e0941ed 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -5,6 +5,101 @@ import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/presentation.dart'; void main() { + testWidgets('la liste affiche le badge exemple des programmes seedés', ( + tester, + ) async { + final programRepository = _FakeProgramRepository() + ..programs.add( + Program( + metadata: _metadata('program-1'), + name: 'Fondations basket - 45 min', + defaultRestSeconds: 45, + isExample: true, + ), + ); + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ProgramListScreen( + programUseCases: _programUseCases( + programRepository, + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('Fondations basket - 45 min'), findsOneWidget); + expect(find.text('Exemple'), findsOneWidget); + expect(find.byTooltip('Supprimer le programme'), findsOneWidget); + }); + + testWidgets('la liste programmes vide conserve le message dédié', ( + tester, + ) async { + final programRepository = _FakeProgramRepository(); + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ProgramListScreen( + programUseCases: _programUseCases( + programRepository, + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('Aucun programme'), findsOneWidget); + expect( + find.text('Assemble quelques exercices pour préparer une séance.'), + findsOneWidget, + ); + }); + + testWidgets('modifier un programme exemple conserve sa provenance', ( + tester, + ) async { + final programRepository = _FakeProgramRepository() + ..programs.add( + Program( + metadata: _metadata('program-1'), + name: 'Fondations basket - 45 min', + defaultRestSeconds: 45, + isExample: true, + ), + ); + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ProgramFormScreen( + programUseCases: _programUseCases( + programRepository, + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + program: programRepository.programs.single, + ), + ), + ); + + await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Edité'); + await tester.tap(find.text('Enregistrer')); + await tester.pumpAndSettle(); + + expect(programRepository.programs.single.name, 'Edité'); + expect(programRepository.programs.single.isExample, isTrue); + }); + testWidgets( 'ajoute un exercice au programme avec sa configuration par défaut', (tester) async { diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index e585574..c9ecc4d 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -7,6 +7,165 @@ import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/presentation.dart'; void main() { + testWidgets('la liste affiche le badge exemple des séances seedées', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository([ + _workoutTemplate(isExample: true), + ]); + 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(); + + expect(find.text('Séance jambes'), findsOneWidget); + expect(find.text('Exemple'), findsOneWidget); + expect(find.byTooltip('Supprimer la séance'), findsOneWidget); + expect(find.text('Lancer'), findsOneWidget); + }); + + testWidgets('la liste séances vide conserve le message dédié', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository(const []); + 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(); + + expect(find.text('Aucune séance'), findsOneWidget); + expect( + find.text( + 'Crée une séance-modèle pour lancer ton entraînement plus vite.', + ), + findsOneWidget, + ); + }); + + testWidgets('modifier une séance exemple conserve sa provenance', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository([ + _workoutTemplate(isExample: true), + ]); + final programRepository = _FakeProgramRepository(); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutTemplateFormScreen( + 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', + ), + template: templateRepository.templates.single, + ), + ), + ); + + await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Editée'); + await tester.tap(find.text('Enregistrer')); + await tester.pumpAndSettle(); + + expect(templateRepository.templates.single.name, 'Editée'); + expect(templateRepository.templates.single.isExample, isTrue); + }); + testWidgets( 'la vue programme de séance ne permet pas de changer les mesures actives', (tester) async { @@ -352,10 +511,11 @@ void main() { }); } -WorkoutTemplate _workoutTemplate() { +WorkoutTemplate _workoutTemplate({bool isExample = false}) { return WorkoutTemplate( metadata: _metadata('template-1'), name: 'Séance jambes', + isExample: isExample, programs: [ WorkoutTemplateProgram( metadata: _metadata('template-program-1'),