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 {}