From 1a0d01f0388467e01515fa80e8e4ae98a63fc122 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 10:33:06 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(gestion):=20suppression=20en=20cascade?= =?UTF-8?q?=20exercice/programme/s=C3=A9ance-mod=C3=A8le=20(ticket=20#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la suppression avec gestion des dépendances en cascade sur les écrans bibliothèque d'exercices, programme et séance-modèle, avec les use cases et le bootstrap applicatif associés. flutter analyze propre, 58/58 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/application/app_bootstrap.dart | 2 + lib/application/use_cases.dart | 156 ++++++++++ lib/presentation/exercise_library_screen.dart | 68 ++++- lib/presentation/program_screen.dart | 57 +++- lib/presentation/workout_template_screen.dart | 41 +++ test/application/use_cases_test.dart | 283 +++++++++++++++++- .../exercise_library_screen_test.dart | 81 ++++- test/presentation/home_screen_test.dart | 2 + test/presentation/program_screen_test.dart | 123 +++++++- .../workout_template_screen_test.dart | 94 +++++- 10 files changed, 881 insertions(+), 26 deletions(-) diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 248c4c6..436ef6f 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -58,6 +58,7 @@ final class AppBootstrap implements AppDependencies { database: database, exerciseUseCases: ExerciseUseCases( repository: exerciseRepository, + programRepository: programRepository, clock: clock, ids: ids, originDeviceId: originDeviceId, @@ -73,6 +74,7 @@ final class AppBootstrap implements AppDependencies { programUseCases: ProgramUseCases( programRepository: programRepository, exerciseRepository: exerciseRepository, + templateRepository: templateRepository, clock: clock, ids: ids, originDeviceId: originDeviceId, diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 38d4881..a780ec0 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -8,12 +8,14 @@ const Object _useCaseUnchanged = Object(); final class ExerciseUseCases { const ExerciseUseCases({ required this.repository, + required this.programRepository, required this.clock, required this.ids, required this.originDeviceId, }); final ExerciseRepository repository; + final ProgramRepository programRepository; final Clock clock; final IdGenerator ids; final String originDeviceId; @@ -181,6 +183,35 @@ final class ExerciseUseCases { await repository.save(archived); return archived; } + + Future delete(String id) async { + final exercise = await repository.findById(id); + if (exercise == null) { + throw const DomainException('Exercise not found.'); + } + final now = clock.now(); + final deleted = exercise.copyWith( + metadata: exercise.metadata.markDeleted(now), + ); + final programs = await programRepository.listActive(); + for (final program in programs) { + final keptExercises = program.exercises + .where((programExercise) => programExercise.sourceExerciseId != id) + .toList(); + if (keptExercises.length == program.exercises.length) { + continue; + } + await programRepository.replaceExercises( + program.copyWith( + metadata: program.metadata.touch(now), + exercises: _repositionProgramExercises(keptExercises, now), + ), + now, + ); + } + await repository.save(deleted); + return deleted; + } } final class MediaUseCases { @@ -272,6 +303,7 @@ final class ProgramUseCases { const ProgramUseCases({ required this.programRepository, required this.exerciseRepository, + required this.templateRepository, required this.clock, required this.ids, required this.originDeviceId, @@ -279,6 +311,7 @@ final class ProgramUseCases { final ProgramRepository programRepository; final ExerciseRepository exerciseRepository; + final WorkoutTemplateRepository templateRepository; final Clock clock; final IdGenerator ids; final String originDeviceId; @@ -287,6 +320,14 @@ final class ProgramUseCases { Future> listActive() => programRepository.listActive(); + Future isUsedInWorkoutTemplates(String id) async { + final templates = await templateRepository.listActive(); + return templates.any( + (template) => + template.programs.any((program) => program.sourceProgramId == id), + ); + } + Future create({ required String name, required int defaultRestSeconds, @@ -399,6 +440,50 @@ final class ProgramUseCases { await programRepository.saveExercise(programExercise); return programExercise; } + + Future delete(String id) async { + final program = await programRepository.findById(id); + if (program == null) { + throw const DomainException('Program not found.'); + } + final now = clock.now(); + final deleted = program.copyWith( + metadata: program.metadata.markDeleted(now), + ); + final templates = await templateRepository.listActive(); + for (final template in templates) { + final keptPrograms = template.programs + .where((templateProgram) => templateProgram.sourceProgramId != id) + .toList(); + if (keptPrograms.length == template.programs.length) { + continue; + } + final repositionedPrograms = _repositionWorkoutTemplatePrograms( + keptPrograms, + now, + ); + final keptProgramIds = repositionedPrograms + .map((templateProgram) => templateProgram.metadata.id) + .toSet(); + await templateRepository.replaceComposition( + WorkoutTemplate( + metadata: template.metadata.touch(now), + name: template.name, + lastStartedAt: template.lastStartedAt, + programs: repositionedPrograms, + overrides: template.overrides + .where( + (override) => + keptProgramIds.contains(override.workoutTemplateProgramId), + ) + .toList(), + ), + now, + ); + } + await programRepository.save(deleted); + return deleted; + } } final class ProgramExerciseConfig { @@ -608,6 +693,21 @@ final class WorkoutTemplateUseCases { await templateRepository.saveOverride(override); return override; } + + Future delete(String id) async { + final template = await templateRepository.findById(id); + if (template == null) { + throw const DomainException('Workout template not found.'); + } + final now = clock.now(); + final deleted = WorkoutTemplate( + metadata: template.metadata.markDeleted(now), + name: template.name, + lastStartedAt: template.lastStartedAt, + ); + await templateRepository.save(deleted); + return deleted; + } } final class WorkoutTemplateProgramConfig { @@ -1323,6 +1423,62 @@ final class SetResultPositionState { final ActiveSetResult? result; } +List _repositionProgramExercises( + List exercises, + DateTime now, +) { + return [ + for (var index = 0; index < exercises.length; index++) + ProgramExercise( + metadata: exercises[index].metadata.touch(now), + programId: exercises[index].programId, + sourceExerciseId: exercises[index].sourceExerciseId, + position: index, + exerciseNameSnapshot: exercises[index].exerciseNameSnapshot, + exerciseDescriptionSnapshot: + exercises[index].exerciseDescriptionSnapshot, + exerciseImageMediaIdSnapshot: + exercises[index].exerciseImageMediaIdSnapshot, + exerciseVideoMediaIdSnapshot: + exercises[index].exerciseVideoMediaIdSnapshot, + exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot, + availableTimeSnapshot: exercises[index].availableTimeSnapshot, + availableRepsSnapshot: exercises[index].availableRepsSnapshot, + availableScoreSnapshot: exercises[index].availableScoreSnapshot, + scoreInputModeSnapshot: exercises[index].scoreInputModeSnapshot, + scoreLabelSnapshot: exercises[index].scoreLabelSnapshot, + scoreUnitSnapshot: exercises[index].scoreUnitSnapshot, + setsCount: exercises[index].setsCount, + timeEnabled: exercises[index].timeEnabled, + repsEnabled: exercises[index].repsEnabled, + scoreEnabled: exercises[index].scoreEnabled, + targetTimeSeconds: exercises[index].targetTimeSeconds, + targetReps: exercises[index].targetReps, + targetScore: exercises[index].targetScore, + targetScoreTimeMs: exercises[index].targetScoreTimeMs, + restSecondsOverride: exercises[index].restSecondsOverride, + ), + ]; +} + +List _repositionWorkoutTemplatePrograms( + List programs, + DateTime now, +) { + return [ + for (var index = 0; index < programs.length; index++) + WorkoutTemplateProgram( + metadata: programs[index].metadata.touch(now), + workoutTemplateId: programs[index].workoutTemplateId, + sourceProgramId: programs[index].sourceProgramId, + position: index, + programNameSnapshot: programs[index].programNameSnapshot, + defaultRestSecondsSnapshot: programs[index].defaultRestSecondsSnapshot, + programSnapshotJson: programs[index].programSnapshotJson, + ), + ]; +} + void _validateExerciseDefaultTargets({ required bool hasTimeMeasure, required bool hasRepsMeasure, diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index d6e8375..169844c 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -157,6 +157,7 @@ final class _ExerciseLibraryScreenState extends State { return ExerciseListTile( exercise: exercise, onTap: () => _openForm(exercise: exercise), + onDelete: () => _confirmDelete(exercise), ); }, childCount: filtered.length * 2 - 1), ), @@ -203,13 +204,64 @@ final class _ExerciseLibraryScreenState extends State { _reload(); } } + + Future _confirmDelete(Exercise exercise) async { + final used = await widget.exerciseUseCases.isUsedInPrograms( + exercise.metadata.id, + ); + if (!mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Supprimer ${exercise.name} ?'), + content: Text( + used + ? 'Cet exercice est utilisé dans un ou plusieurs programmes. ' + 'Le supprimer le retirera de ces programmes. Continuer ?' + : 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton.icon( + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(Icons.delete_outline), + label: const Text('Supprimer'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await widget.exerciseUseCases.delete(exercise.metadata.id); + if (!mounted) return; + _reload(); + } on Exception catch (error) { + _showSnackBar(error.toString()); + } + } + + void _showSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } } final class ExerciseListTile extends StatelessWidget { - const ExerciseListTile({required this.exercise, this.onTap, super.key}); + const ExerciseListTile({ + required this.exercise, + this.onTap, + this.onDelete, + super.key, + }); final Exercise exercise; final VoidCallback? onTap; + final VoidCallback? onDelete; @override Widget build(BuildContext context) { @@ -258,7 +310,19 @@ final class ExerciseListTile extends StatelessWidget { ), ], ), - trailing: const Icon(Icons.chevron_right), + trailing: onDelete == null + ? const Icon(Icons.chevron_right) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Supprimer l’exercice', + icon: const Icon(Icons.delete_outline), + onPressed: onDelete, + ), + const Icon(Icons.chevron_right), + ], + ), ); } } diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index f29537f..a358b66 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -69,7 +69,17 @@ final class _ProgramListScreenState extends State { return ListTile( title: Text(program.name), subtitle: Text(_programSummary(program)), - trailing: const Icon(Icons.chevron_right), + trailing: Wrap( + spacing: 8, + children: [ + IconButton( + tooltip: 'Supprimer le programme', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(program), + ), + const Icon(Icons.chevron_right), + ], + ), onTap: () => _openForm(program: program), ); }, @@ -100,6 +110,51 @@ final class _ProgramListScreenState extends State { _reload(); } } + + Future _confirmDelete(Program program) async { + final used = await widget.programUseCases.isUsedInWorkoutTemplates( + program.metadata.id, + ); + if (!mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Supprimer ${program.name} ?'), + content: Text( + used + ? 'Ce programme est utilisé dans une ou plusieurs séances. ' + 'Le supprimer le retirera de ces séances. Continuer ?' + : 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton.icon( + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(Icons.delete_outline), + label: const Text('Supprimer'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await widget.programUseCases.delete(program.metadata.id); + if (!mounted) return; + _reload(); + } on Exception catch (error) { + _showSnackBar(error.toString()); + } + } + + void _showSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } } final class ProgramFormScreen extends StatefulWidget { diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index a95d7ed..0718165 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -72,6 +72,11 @@ final class _WorkoutTemplateListScreenState 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'), @@ -140,6 +145,42 @@ final class _WorkoutTemplateListScreenState ), ); } + + Future _confirmDelete(WorkoutTemplate template) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Supprimer ${template.name} ?'), + content: const Text('Cette action est irréversible.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton.icon( + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(Icons.delete_outline), + label: const Text('Supprimer'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await widget.workoutTemplateUseCases.delete(template.metadata.id); + if (!mounted) return; + _reload(); + } on Exception catch (error) { + _showSnackBar(error.toString()); + } + } + + void _showSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } } final class WorkoutTemplateFormScreen extends StatefulWidget { diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 1659246..e7d47e6 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -156,6 +156,165 @@ void main() { ); }); + test('delete exercise removes it from referencing programs', () async { + final exerciseRepository = _FakeExerciseRepository() + ..exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ); + final programRepository = _FakeProgramRepository() + ..programs.add( + Program( + metadata: _metadata('program-1'), + name: 'Jambes', + defaultRestSeconds: 60, + exercises: [ + _programExercise( + id: 'program-exercise-1', + programId: 'program-1', + sourceExerciseId: 'exercise-1', + position: 0, + ), + _programExercise( + id: 'program-exercise-2', + programId: 'program-1', + sourceExerciseId: 'exercise-2', + position: 1, + ), + ], + ), + ); + final useCase = _exerciseUseCase( + exerciseRepository, + programRepository: programRepository, + ); + + final deleted = await useCase.delete('exercise-1'); + + expect(deleted.metadata.deletedAt, isNotNull); + expect(exerciseRepository.listActive(), completion(isEmpty)); + expect(programRepository.programs.single.exercises, hasLength(1)); + expect( + programRepository.programs.single.exercises.single.sourceExerciseId, + 'exercise-2', + ); + expect(programRepository.programs.single.exercises.single.position, 0); + }); + + test( + 'delete program removes it from referencing workout templates', + () async { + final programRepository = _FakeProgramRepository() + ..programs.add( + Program( + metadata: _metadata('program-1'), + name: 'Jambes', + defaultRestSeconds: 60, + ), + ); + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add( + WorkoutTemplate( + metadata: _metadata('template-1'), + name: 'Séance', + programs: [ + _templateProgram( + id: 'template-program-1', + workoutTemplateId: 'template-1', + sourceProgramId: 'program-1', + position: 0, + ), + _templateProgram( + id: 'template-program-2', + workoutTemplateId: 'template-1', + sourceProgramId: 'program-2', + position: 1, + ), + ], + overrides: [ + WorkoutTemplateExerciseOverride( + metadata: _metadata('override-1'), + workoutTemplateProgramId: 'template-program-1', + snapshotProgramExerciseId: 'exercise-snapshot-1', + targetRepsOverride: 8, + ), + ], + ), + ); + final useCase = ProgramUseCases( + programRepository: programRepository, + exerciseRepository: _FakeExerciseRepository(), + templateRepository: templateRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final deleted = await useCase.delete('program-1'); + + expect(deleted.metadata.deletedAt, isNotNull); + expect(programRepository.listActive(), completion(isEmpty)); + final template = templateRepository.templates.single; + expect(template.programs, hasLength(1)); + expect(template.programs.single.sourceProgramId, 'program-2'); + expect(template.programs.single.position, 0); + expect(template.overrides, isEmpty); + }, + ); + + test('delete workout template hides it from active list', () async { + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add( + WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'), + ); + final useCase = WorkoutTemplateUseCases( + templateRepository: templateRepository, + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final deleted = await useCase.delete('template-1'); + + expect(deleted.metadata.deletedAt, isNotNull); + expect(templateRepository.listActive(), completion(isEmpty)); + }); + + test('history stays unchanged after deleting its source template', () async { + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add( + WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'), + ); + final history = WorkoutHistory( + metadata: _metadata('history-1'), + sourceWorkoutTemplateId: 'template-1', + nameSnapshot: 'Séance historique', + startedAt: DateTime.utc(2026, 7, 17, 10), + endedAt: DateTime.utc(2026, 7, 17, 11), + totalActiveMs: 3600000, + completed: true, + historySnapshotJson: '{"name":"Séance historique"}', + ); + final useCase = WorkoutTemplateUseCases( + templateRepository: templateRepository, + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await useCase.delete('template-1'); + + expect(history.sourceWorkoutTemplateId, 'template-1'); + expect(history.nameSnapshot, 'Séance historique'); + expect(history.historySnapshotJson, '{"name":"Séance historique"}'); + }); + test('Workout template use case rejects active measure overrides', () async { final useCase = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), @@ -612,15 +771,59 @@ final class _FakeIds implements IdGenerator { } } -ExerciseUseCases _exerciseUseCase(_FakeExerciseRepository repository) { +ExerciseUseCases _exerciseUseCase( + _FakeExerciseRepository repository, { + _FakeProgramRepository? programRepository, +}) { return ExerciseUseCases( repository: repository, + programRepository: programRepository ?? _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } +ProgramExercise _programExercise({ + required String id, + required String programId, + required String sourceExerciseId, + required int position, +}) { + return ProgramExercise( + metadata: _metadata(id), + programId: programId, + sourceExerciseId: sourceExerciseId, + position: position, + exerciseNameSnapshot: 'Exercise $id', + availableTimeSnapshot: false, + availableRepsSnapshot: true, + availableScoreSnapshot: false, + setsCount: 3, + timeEnabled: false, + repsEnabled: true, + scoreEnabled: false, + targetReps: 10, + ); +} + +WorkoutTemplateProgram _templateProgram({ + required String id, + required String workoutTemplateId, + required String sourceProgramId, + required int position, +}) { + return WorkoutTemplateProgram( + metadata: _metadata(id), + workoutTemplateId: workoutTemplateId, + sourceProgramId: sourceProgramId, + position: position, + programNameSnapshot: 'Program $id', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: '{"exercises":[]}', + ); +} + final class _FakeExerciseRepository implements ExerciseRepository { Exercise? exercise; final saved = []; @@ -635,7 +838,9 @@ final class _FakeExerciseRepository implements ExerciseRepository { @override Future> listActive() async => - exercise == null ? const [] : [exercise!]; + exercise == null || exercise!.metadata.deletedAt != null + ? const [] + : [exercise!]; @override Future save(Exercise exercise) async { @@ -646,20 +851,38 @@ final class _FakeExerciseRepository implements ExerciseRepository { final class _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { - @override - Future findById(String id) async => null; + final templates = []; @override - Future> listActive() async => const []; + Future findById(String id) async { + for (final template in templates) { + if (template.metadata.id == id && template.metadata.deletedAt == null) { + return template; + } + } + return null; + } + + @override + Future> listActive() async { + return templates + .where((template) => template.metadata.deletedAt == null) + .toList(); + } @override Future replaceComposition( WorkoutTemplate template, DateTime deletedAt, - ) async {} + ) async { + await save(template); + } @override - Future save(WorkoutTemplate template) async {} + Future save(WorkoutTemplate template) async { + templates.removeWhere((saved) => saved.metadata.id == template.metadata.id); + templates.add(template); + } @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @@ -669,20 +892,54 @@ final class _FakeWorkoutTemplateRepository } final class _FakeProgramRepository implements ProgramRepository { - @override - Future findById(String id) async => null; + final programs = []; @override - Future> listActive() async => const []; + Future findById(String id) async { + for (final program in programs) { + if (program.metadata.id == id && program.metadata.deletedAt == null) { + return program; + } + } + return null; + } @override - Future replaceExercises(Program program, DateTime deletedAt) async {} + Future> listActive() async { + return programs + .where((program) => program.metadata.deletedAt == null) + .toList(); + } @override - Future save(Program program) async {} + Future replaceExercises(Program program, DateTime deletedAt) async { + await save(program); + } @override - Future saveExercise(ProgramExercise exercise) async {} + Future save(Program program) async { + programs.removeWhere((saved) => saved.metadata.id == program.metadata.id); + programs.add(program); + } + + @override + Future saveExercise(ProgramExercise exercise) async { + final index = programs.indexWhere( + (program) => program.metadata.id == exercise.programId, + ); + if (index == -1) { + return; + } + final program = programs[index]; + programs[index] = program.copyWith( + exercises: [ + ...program.exercises.where( + (saved) => saved.metadata.id != exercise.metadata.id, + ), + exercise, + ], + ); + } } ActiveWorkoutSessionUseCases _activeUseCase( diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index c57f5e7..67c006d 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -148,17 +148,87 @@ void main() { expect(find.text('Image importée.'), findsOneWidget); }, ); + + testWidgets( + 'supprimer un exercice demande confirmation puis le retire de la liste', + (tester) async { + final exerciseRepository = _FakeExerciseRepository() + ..referenced = true + ..exercises.add( + Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + defaultTargetTimeSeconds: 45, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: ExerciseLibraryScreen( + exerciseUseCases: _exerciseUseCases(exerciseRepository), + mediaUseCases: _mediaUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byTooltip('Supprimer l’exercice')); + await tester.pumpAndSettle(); + + expect(find.text('Supprimer Squat ?'), findsOneWidget); + expect( + find.textContaining('Le supprimer le retirera de ces programmes.'), + findsOneWidget, + ); + + await tester.tap(find.text('Annuler')); + await tester.pumpAndSettle(); + + expect(find.text('Squat'), findsOneWidget); + expect(exerciseRepository.saved, isEmpty); + + await tester.tap(find.byTooltip('Supprimer l’exercice')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); + await tester.pumpAndSettle(); + + expect(find.text('Squat'), findsNothing); + expect(exerciseRepository.saved.single.metadata.deletedAt, isNotNull); + }, + ); } ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { return ExerciseUseCases( repository: repository, + programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } +final class _FakeProgramRepository implements ProgramRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +} + MediaUseCases _mediaUseCases( _FakeExerciseRepository exerciseRepository, { _FakeMediaStorage? storage, @@ -174,16 +244,15 @@ MediaUseCases _mediaUseCases( } final class _FakeMediaPicker implements MediaSourcePicker { - const _FakeMediaPicker({this.imagePath, this.videoPath}); + const _FakeMediaPicker({this.imagePath}); final String? imagePath; - final String? videoPath; @override Future pickPath(MediaKind kind) async { return switch (kind) { MediaKind.image => imagePath, - MediaKind.video => videoPath, + MediaKind.video => null, }; } } @@ -235,7 +304,11 @@ final class _FakeExerciseRepository implements ExerciseRepository { Future isReferencedByProgram(String id) async => referenced; @override - Future> listActive() async => exercises; + Future> listActive() async { + return exercises + .where((exercise) => exercise.metadata.deletedAt == null) + .toList(); + } @override Future save(Exercise exercise) async { diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index e86a799..98d030f 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -131,6 +131,7 @@ final class _FakeBootstrap implements AppDependencies { ), exerciseUseCases = ExerciseUseCases( repository: _FakeExerciseRepository(), + programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', @@ -146,6 +147,7 @@ final class _FakeBootstrap implements AppDependencies { programUseCases = ProgramUseCases( programRepository: _FakeProgramRepository(), exerciseRepository: _FakeExerciseRepository(), + templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 2f371a1..b5a58ea 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -233,11 +233,83 @@ void main() { findsOneWidget, ); }); + + testWidgets( + 'supprimer un programme demande confirmation puis le retire de la liste', + (tester) async { + final programRepository = _FakeProgramRepository() + ..programs.add( + Program( + metadata: _metadata('program-1'), + name: 'Jambes', + defaultRestSeconds: 60, + ), + ); + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add( + WorkoutTemplate( + metadata: _metadata('template-1'), + name: 'Séance jambes', + programs: [ + WorkoutTemplateProgram( + metadata: _metadata('template-program-1'), + workoutTemplateId: 'template-1', + sourceProgramId: 'program-1', + position: 0, + programNameSnapshot: 'Jambes', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: '{"exercises":[]}', + ), + ], + ), + ); + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ProgramListScreen( + programUseCases: _programUseCases( + programRepository, + exerciseRepository, + templateRepository: templateRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byTooltip('Supprimer le programme')); + await tester.pumpAndSettle(); + + expect(find.text('Supprimer Jambes ?'), findsOneWidget); + expect( + find.textContaining('Le supprimer le retirera de ces séances.'), + findsOneWidget, + ); + + await tester.tap(find.text('Annuler')); + await tester.pumpAndSettle(); + + expect(find.text('Jambes'), findsOneWidget); + expect(programRepository.saved, isEmpty); + + await tester.tap(find.byTooltip('Supprimer le programme')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); + await tester.pumpAndSettle(); + + expect(find.text('Jambes'), findsNothing); + expect(programRepository.saved.single.metadata.deletedAt, isNotNull); + }, + ); } ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { return ExerciseUseCases( repository: repository, + programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', @@ -246,17 +318,55 @@ ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { ProgramUseCases _programUseCases( _FakeProgramRepository programRepository, - _FakeExerciseRepository exerciseRepository, -) { + _FakeExerciseRepository exerciseRepository, { + _FakeWorkoutTemplateRepository? templateRepository, +}) { return ProgramUseCases( programRepository: programRepository, exerciseRepository: exerciseRepository, + templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + final templates = []; + + @override + Future findById(String id) async => null; + + @override + Future> listActive() async { + return templates + .where((template) => template.metadata.deletedAt == null) + .toList(); + } + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async { + templates.removeWhere((item) => item.metadata.id == template.metadata.id); + templates.add(template); + } + + @override + Future save(WorkoutTemplate template) async { + templates.removeWhere((item) => item.metadata.id == template.metadata.id); + templates.add(template); + } + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} + EntityMetadata _metadata(String id) { return EntityMetadata( id: id, @@ -313,6 +423,7 @@ final class _FakeExerciseRepository implements ExerciseRepository { final class _FakeProgramRepository implements ProgramRepository { final programs = []; + final saved = []; @override Future findById(String id) async { @@ -325,7 +436,11 @@ final class _FakeProgramRepository implements ProgramRepository { } @override - Future> listActive() async => programs; + Future> listActive() async { + return programs + .where((program) => program.metadata.deletedAt == null) + .toList(); + } @override Future replaceExercises(Program program, DateTime deletedAt) async { @@ -335,6 +450,8 @@ final class _FakeProgramRepository implements ProgramRepository { @override Future save(Program program) async { + saved.add(program); + programs.removeWhere((item) => item.metadata.id == program.metadata.id); programs.add(program); } diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index bb323a7..4ec57f4 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -69,6 +69,7 @@ void main() { programUseCases: ProgramUseCases( programRepository: programRepository, exerciseRepository: _FakeExerciseRepository(), + templateRepository: templateRepository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', @@ -108,6 +109,81 @@ void main() { expect(activeRepository.saveCount, 0); }, ); + + testWidgets( + 'supprimer une séance demande confirmation puis la retire de la liste', + (tester) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository([ + _workoutTemplate(), + ]); + 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('Supprimer la séance')); + await tester.pumpAndSettle(); + + expect(find.text('Supprimer Séance jambes ?'), findsOneWidget); + expect(find.text('Cette action est irréversible.'), findsOneWidget); + + await tester.tap(find.text('Annuler')); + await tester.pumpAndSettle(); + + expect(find.text('Séance jambes'), findsOneWidget); + expect(templateRepository.saved, isEmpty); + + await tester.tap(find.byTooltip('Supprimer la séance')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); + await tester.pumpAndSettle(); + + expect(find.text('Séance jambes'), findsNothing); + expect(templateRepository.saved.single.metadata.deletedAt, isNotNull); + }, + ); } WorkoutTemplate _workoutTemplate() { @@ -201,6 +277,7 @@ final class _FakeWorkoutTemplateRepository _FakeWorkoutTemplateRepository(this.templates); final List templates; + final saved = []; @override Future findById(String id) async { @@ -211,16 +288,27 @@ final class _FakeWorkoutTemplateRepository } @override - Future> listActive() async => templates; + Future> listActive() async { + return templates + .where((template) => template.metadata.deletedAt == null) + .toList(); + } @override Future replaceComposition( WorkoutTemplate template, DateTime deletedAt, - ) async {} + ) async { + templates.removeWhere((item) => item.metadata.id == template.metadata.id); + templates.add(template); + } @override - Future save(WorkoutTemplate template) async {} + Future save(WorkoutTemplate template) async { + saved.add(template); + templates.removeWhere((item) => item.metadata.id == template.metadata.id); + templates.add(template); + } @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} From 79a1d8ba3ca4d14236ab6a1813816a87ddec9097 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 10:33:09 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20le=20t?= =?UTF-8?q?icket=20#37=20apr=C3=A8s=20la=20suppression=20en=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/37/carnet.md | 11 ++++++++--- .ideai/tickets/37/issue.md | 8 ++++---- .ideai/tickets/index.json | 4 ++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.ideai/tickets/37/carnet.md b/.ideai/tickets/37/carnet.md index 06881f6..ef1243a 100644 --- a/.ideai/tickets/37/carnet.md +++ b/.ideai/tickets/37/carnet.md @@ -1,6 +1,11 @@ --- issueRef: "#37" -version: 3 -updatedBy: {"kind":"user"} -updatedAt: 1784330063749 +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784363570119 --- +Découpage proposé : +1. [DevBackend] Use cases de suppression avec cascade : deleteExercise (retire l'exercice de tous les programmes qui le référencent, soft delete via metadata.deletedAt déjà existant sur EntityMetadata — pas de nouveau champ nécessaire), deleteProgram (retire le programme de toutes les séances-modèles qui le référencent), deleteWorkoutTemplate (soft delete simple, pas de cascade car rien ne référence une séance-modèle sauf l'historique qui garde son propre snapshot autonome déjà indépendant). S'assurer que listActive()/repositories filtrent bien deletedAt != null partout où c'est déjà censé être le cas. +2. [DevFrontend] Boutons de suppression + confirmations sur les 3 écrans (bibliothèque d'exercices, liste de programmes, liste de séances-modèles), avec les messages de confirmation adaptés (cf. description utilisateur du ticket : cascade expliquée si pertinent). Vérifier que la relance d'une séance dont la séance-modèle source a été supprimée affiche bien le message déjà prévu par la conception initiale ("La séance originale n'existe plus. Une copie va être utilisée.") — c'est déjà censé être géré par le pattern historique existant (ticket #10/#28), à vérifier/compléter si besoin plutôt qu'à recréer. + +Important : l'historique doit rester lisible même après suppression (déjà garanti par le pattern snapshot autonome existant sur WorkoutHistory) — ne pas casser ça. \ No newline at end of file diff --git a/.ideai/tickets/37/issue.md b/.ideai/tickets/37/issue.md index f0ad244..93ff81e 100644 --- a/.ideai/tickets/37/issue.md +++ b/.ideai/tickets/37/issue.md @@ -2,15 +2,15 @@ id: "d472ee06-f4f1-4796-9797-b5d8a65000f3" number: 37 title: "Pouvoir supprimer un programme, une séance ou un exercice" -status: "open" +status: "closed" priority: "high" sprint: null links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784329898629 -updatedAt: 1784330063749 -version: 3 +updatedAt: 1784363570119 +version: 6 --- Il faut que l'utilisateur puisse supprimer un exercice qu'il a créé, un programme qu'il a créé ou une séance qu'il a créé. Si on supprime une exerccie, alors il est retiré des programmes dans lequel il se trouve. Si on supprime un programme alors il est supprimé des séances dans lequel il se trouve. Pour l'historique, il faut cependant que les datas soient persistantes, même si un exercice a été supprimé par exemple, il faut quand meme quie l'on puisse garder les données d'affichées. La séance ne pourra par contre pas être relancée, il faudra donc afficher un message qui l'explique. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 38fc634..954ace3 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -417,13 +417,13 @@ "issueRef": "#37", "path": "37", "title": "Pouvoir supprimer un programme, une séance ou un exercice", - "status": "open", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784330063749 + "updatedAt": 1784363570119 }, { "issueRef": "#38",