merge(main): suppression en cascade exercice/programme/séance-modèle (ticket #37)
Fusionne feature/#37-suppression-programme-seance-exercice — analyze propre, 58/58 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,11 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#37"
|
issueRef: "#37"
|
||||||
version: 3
|
version: 6
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
updatedAt: 1784330063749
|
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.
|
||||||
@ -2,15 +2,15 @@
|
|||||||
id: "d472ee06-f4f1-4796-9797-b5d8a65000f3"
|
id: "d472ee06-f4f1-4796-9797-b5d8a65000f3"
|
||||||
number: 37
|
number: 37
|
||||||
title: "Pouvoir supprimer un programme, une séance ou un exercice"
|
title: "Pouvoir supprimer un programme, une séance ou un exercice"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: []
|
links: []
|
||||||
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
|
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
|
||||||
createdBy: {"kind":"user"}
|
createdBy: {"kind":"user"}
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
createdAt: 1784329898629
|
createdAt: 1784329898629
|
||||||
updatedAt: 1784330063749
|
updatedAt: 1784363570119
|
||||||
version: 3
|
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.
|
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.
|
||||||
@ -417,13 +417,13 @@
|
|||||||
"issueRef": "#37",
|
"issueRef": "#37",
|
||||||
"path": "37",
|
"path": "37",
|
||||||
"title": "Pouvoir supprimer un programme, une séance ou un exercice",
|
"title": "Pouvoir supprimer un programme, une séance ou un exercice",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [
|
"assignedAgentIds": [
|
||||||
"57695b92-24d0-4876-837c-76116e70a6ae"
|
"57695b92-24d0-4876-837c-76116e70a6ae"
|
||||||
],
|
],
|
||||||
"updatedAt": 1784330063749
|
"updatedAt": 1784363570119
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#38",
|
"issueRef": "#38",
|
||||||
|
|||||||
@ -58,6 +58,7 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
database: database,
|
database: database,
|
||||||
exerciseUseCases: ExerciseUseCases(
|
exerciseUseCases: ExerciseUseCases(
|
||||||
repository: exerciseRepository,
|
repository: exerciseRepository,
|
||||||
|
programRepository: programRepository,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
ids: ids,
|
ids: ids,
|
||||||
originDeviceId: originDeviceId,
|
originDeviceId: originDeviceId,
|
||||||
@ -73,6 +74,7 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
programUseCases: ProgramUseCases(
|
programUseCases: ProgramUseCases(
|
||||||
programRepository: programRepository,
|
programRepository: programRepository,
|
||||||
exerciseRepository: exerciseRepository,
|
exerciseRepository: exerciseRepository,
|
||||||
|
templateRepository: templateRepository,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
ids: ids,
|
ids: ids,
|
||||||
originDeviceId: originDeviceId,
|
originDeviceId: originDeviceId,
|
||||||
|
|||||||
@ -8,12 +8,14 @@ const Object _useCaseUnchanged = Object();
|
|||||||
final class ExerciseUseCases {
|
final class ExerciseUseCases {
|
||||||
const ExerciseUseCases({
|
const ExerciseUseCases({
|
||||||
required this.repository,
|
required this.repository,
|
||||||
|
required this.programRepository,
|
||||||
required this.clock,
|
required this.clock,
|
||||||
required this.ids,
|
required this.ids,
|
||||||
required this.originDeviceId,
|
required this.originDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ExerciseRepository repository;
|
final ExerciseRepository repository;
|
||||||
|
final ProgramRepository programRepository;
|
||||||
final Clock clock;
|
final Clock clock;
|
||||||
final IdGenerator ids;
|
final IdGenerator ids;
|
||||||
final String originDeviceId;
|
final String originDeviceId;
|
||||||
@ -181,6 +183,35 @@ final class ExerciseUseCases {
|
|||||||
await repository.save(archived);
|
await repository.save(archived);
|
||||||
return archived;
|
return archived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Exercise> 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 {
|
final class MediaUseCases {
|
||||||
@ -272,6 +303,7 @@ final class ProgramUseCases {
|
|||||||
const ProgramUseCases({
|
const ProgramUseCases({
|
||||||
required this.programRepository,
|
required this.programRepository,
|
||||||
required this.exerciseRepository,
|
required this.exerciseRepository,
|
||||||
|
required this.templateRepository,
|
||||||
required this.clock,
|
required this.clock,
|
||||||
required this.ids,
|
required this.ids,
|
||||||
required this.originDeviceId,
|
required this.originDeviceId,
|
||||||
@ -279,6 +311,7 @@ final class ProgramUseCases {
|
|||||||
|
|
||||||
final ProgramRepository programRepository;
|
final ProgramRepository programRepository;
|
||||||
final ExerciseRepository exerciseRepository;
|
final ExerciseRepository exerciseRepository;
|
||||||
|
final WorkoutTemplateRepository templateRepository;
|
||||||
final Clock clock;
|
final Clock clock;
|
||||||
final IdGenerator ids;
|
final IdGenerator ids;
|
||||||
final String originDeviceId;
|
final String originDeviceId;
|
||||||
@ -287,6 +320,14 @@ final class ProgramUseCases {
|
|||||||
|
|
||||||
Future<List<Program>> listActive() => programRepository.listActive();
|
Future<List<Program>> listActive() => programRepository.listActive();
|
||||||
|
|
||||||
|
Future<bool> isUsedInWorkoutTemplates(String id) async {
|
||||||
|
final templates = await templateRepository.listActive();
|
||||||
|
return templates.any(
|
||||||
|
(template) =>
|
||||||
|
template.programs.any((program) => program.sourceProgramId == id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Program> create({
|
Future<Program> create({
|
||||||
required String name,
|
required String name,
|
||||||
required int defaultRestSeconds,
|
required int defaultRestSeconds,
|
||||||
@ -399,6 +440,50 @@ final class ProgramUseCases {
|
|||||||
await programRepository.saveExercise(programExercise);
|
await programRepository.saveExercise(programExercise);
|
||||||
return programExercise;
|
return programExercise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Program> 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 {
|
final class ProgramExerciseConfig {
|
||||||
@ -608,6 +693,21 @@ final class WorkoutTemplateUseCases {
|
|||||||
await templateRepository.saveOverride(override);
|
await templateRepository.saveOverride(override);
|
||||||
return override;
|
return override;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<WorkoutTemplate> 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 {
|
final class WorkoutTemplateProgramConfig {
|
||||||
@ -1323,6 +1423,62 @@ final class SetResultPositionState {
|
|||||||
final ActiveSetResult? result;
|
final ActiveSetResult? result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<ProgramExercise> _repositionProgramExercises(
|
||||||
|
List<ProgramExercise> 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<WorkoutTemplateProgram> _repositionWorkoutTemplatePrograms(
|
||||||
|
List<WorkoutTemplateProgram> 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({
|
void _validateExerciseDefaultTargets({
|
||||||
required bool hasTimeMeasure,
|
required bool hasTimeMeasure,
|
||||||
required bool hasRepsMeasure,
|
required bool hasRepsMeasure,
|
||||||
|
|||||||
@ -157,6 +157,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
return ExerciseListTile(
|
return ExerciseListTile(
|
||||||
exercise: exercise,
|
exercise: exercise,
|
||||||
onTap: () => _openForm(exercise: exercise),
|
onTap: () => _openForm(exercise: exercise),
|
||||||
|
onDelete: () => _confirmDelete(exercise),
|
||||||
);
|
);
|
||||||
}, childCount: filtered.length * 2 - 1),
|
}, childCount: filtered.length * 2 - 1),
|
||||||
),
|
),
|
||||||
@ -203,13 +204,64 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
_reload();
|
_reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(Exercise exercise) async {
|
||||||
|
final used = await widget.exerciseUseCases.isUsedInPrograms(
|
||||||
|
exercise.metadata.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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 {
|
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 Exercise exercise;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,17 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(program.name),
|
title: Text(program.name),
|
||||||
subtitle: Text(_programSummary(program)),
|
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),
|
onTap: () => _openForm(program: program),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -100,6 +110,51 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
|
|||||||
_reload();
|
_reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(Program program) async {
|
||||||
|
final used = await widget.programUseCases.isUsedInWorkoutTemplates(
|
||||||
|
program.metadata.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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 {
|
final class ProgramFormScreen extends StatefulWidget {
|
||||||
|
|||||||
@ -72,6 +72,11 @@ final class _WorkoutTemplateListScreenState
|
|||||||
trailing: Wrap(
|
trailing: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Supprimer la séance',
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => _confirmDelete(template),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _start(template),
|
onPressed: () => _start(template),
|
||||||
child: const Text('Lancer'),
|
child: const Text('Lancer'),
|
||||||
@ -140,6 +145,42 @@ final class _WorkoutTemplateListScreenState
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(WorkoutTemplate template) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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 {
|
final class WorkoutTemplateFormScreen extends StatefulWidget {
|
||||||
|
|||||||
@ -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 {
|
test('Workout template use case rejects active measure overrides', () async {
|
||||||
final useCase = WorkoutTemplateUseCases(
|
final useCase = WorkoutTemplateUseCases(
|
||||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||||
@ -612,15 +771,59 @@ final class _FakeIds implements IdGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ExerciseUseCases _exerciseUseCase(_FakeExerciseRepository repository) {
|
ExerciseUseCases _exerciseUseCase(
|
||||||
|
_FakeExerciseRepository repository, {
|
||||||
|
_FakeProgramRepository? programRepository,
|
||||||
|
}) {
|
||||||
return ExerciseUseCases(
|
return ExerciseUseCases(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
|
programRepository: programRepository ?? _FakeProgramRepository(),
|
||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
originDeviceId: 'device-1',
|
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 {
|
final class _FakeExerciseRepository implements ExerciseRepository {
|
||||||
Exercise? exercise;
|
Exercise? exercise;
|
||||||
final saved = <Exercise>[];
|
final saved = <Exercise>[];
|
||||||
@ -635,7 +838,9 @@ final class _FakeExerciseRepository implements ExerciseRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Exercise>> listActive() async =>
|
Future<List<Exercise>> listActive() async =>
|
||||||
exercise == null ? const [] : [exercise!];
|
exercise == null || exercise!.metadata.deletedAt != null
|
||||||
|
? const []
|
||||||
|
: [exercise!];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(Exercise exercise) async {
|
Future<void> save(Exercise exercise) async {
|
||||||
@ -646,20 +851,38 @@ final class _FakeExerciseRepository implements ExerciseRepository {
|
|||||||
|
|
||||||
final class _FakeWorkoutTemplateRepository
|
final class _FakeWorkoutTemplateRepository
|
||||||
implements WorkoutTemplateRepository {
|
implements WorkoutTemplateRepository {
|
||||||
@override
|
final templates = <WorkoutTemplate>[];
|
||||||
Future<WorkoutTemplate?> findById(String id) async => null;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<WorkoutTemplate>> listActive() async => const [];
|
Future<WorkoutTemplate?> findById(String id) async {
|
||||||
|
for (final template in templates) {
|
||||||
|
if (template.metadata.id == id && template.metadata.deletedAt == null) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<WorkoutTemplate>> listActive() async {
|
||||||
|
return templates
|
||||||
|
.where((template) => template.metadata.deletedAt == null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> replaceComposition(
|
Future<void> replaceComposition(
|
||||||
WorkoutTemplate template,
|
WorkoutTemplate template,
|
||||||
DateTime deletedAt,
|
DateTime deletedAt,
|
||||||
) async {}
|
) async {
|
||||||
|
await save(template);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(WorkoutTemplate template) async {}
|
Future<void> save(WorkoutTemplate template) async {
|
||||||
|
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
|
||||||
|
templates.add(template);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||||
@ -669,20 +892,54 @@ final class _FakeWorkoutTemplateRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class _FakeProgramRepository implements ProgramRepository {
|
final class _FakeProgramRepository implements ProgramRepository {
|
||||||
@override
|
final programs = <Program>[];
|
||||||
Future<Program?> findById(String id) async => null;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Program>> listActive() async => const [];
|
Future<Program?> findById(String id) async {
|
||||||
|
for (final program in programs) {
|
||||||
|
if (program.metadata.id == id && program.metadata.deletedAt == null) {
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
Future<List<Program>> listActive() async {
|
||||||
|
return programs
|
||||||
|
.where((program) => program.metadata.deletedAt == null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(Program program) async {}
|
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
|
||||||
|
await save(program);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveExercise(ProgramExercise exercise) async {}
|
Future<void> save(Program program) async {
|
||||||
|
programs.removeWhere((saved) => saved.metadata.id == program.metadata.id);
|
||||||
|
programs.add(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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(
|
ActiveWorkoutSessionUseCases _activeUseCase(
|
||||||
|
|||||||
@ -148,17 +148,87 @@ void main() {
|
|||||||
expect(find.text('Image importée.'), findsOneWidget);
|
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) {
|
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
|
||||||
return ExerciseUseCases(
|
return ExerciseUseCases(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
|
programRepository: _FakeProgramRepository(),
|
||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
originDeviceId: 'device-1',
|
originDeviceId: 'device-1',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _FakeProgramRepository implements ProgramRepository {
|
||||||
|
@override
|
||||||
|
Future<Program?> findById(String id) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Program>> listActive() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(Program program) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||||||
|
}
|
||||||
|
|
||||||
MediaUseCases _mediaUseCases(
|
MediaUseCases _mediaUseCases(
|
||||||
_FakeExerciseRepository exerciseRepository, {
|
_FakeExerciseRepository exerciseRepository, {
|
||||||
_FakeMediaStorage? storage,
|
_FakeMediaStorage? storage,
|
||||||
@ -174,16 +244,15 @@ MediaUseCases _mediaUseCases(
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class _FakeMediaPicker implements MediaSourcePicker {
|
final class _FakeMediaPicker implements MediaSourcePicker {
|
||||||
const _FakeMediaPicker({this.imagePath, this.videoPath});
|
const _FakeMediaPicker({this.imagePath});
|
||||||
|
|
||||||
final String? imagePath;
|
final String? imagePath;
|
||||||
final String? videoPath;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> pickPath(MediaKind kind) async {
|
Future<String?> pickPath(MediaKind kind) async {
|
||||||
return switch (kind) {
|
return switch (kind) {
|
||||||
MediaKind.image => imagePath,
|
MediaKind.image => imagePath,
|
||||||
MediaKind.video => videoPath,
|
MediaKind.video => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -235,7 +304,11 @@ final class _FakeExerciseRepository implements ExerciseRepository {
|
|||||||
Future<bool> isReferencedByProgram(String id) async => referenced;
|
Future<bool> isReferencedByProgram(String id) async => referenced;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Exercise>> listActive() async => exercises;
|
Future<List<Exercise>> listActive() async {
|
||||||
|
return exercises
|
||||||
|
.where((exercise) => exercise.metadata.deletedAt == null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(Exercise exercise) async {
|
Future<void> save(Exercise exercise) async {
|
||||||
|
|||||||
@ -131,6 +131,7 @@ final class _FakeBootstrap implements AppDependencies {
|
|||||||
),
|
),
|
||||||
exerciseUseCases = ExerciseUseCases(
|
exerciseUseCases = ExerciseUseCases(
|
||||||
repository: _FakeExerciseRepository(),
|
repository: _FakeExerciseRepository(),
|
||||||
|
programRepository: _FakeProgramRepository(),
|
||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
originDeviceId: 'device-1',
|
originDeviceId: 'device-1',
|
||||||
@ -146,6 +147,7 @@ final class _FakeBootstrap implements AppDependencies {
|
|||||||
programUseCases = ProgramUseCases(
|
programUseCases = ProgramUseCases(
|
||||||
programRepository: _FakeProgramRepository(),
|
programRepository: _FakeProgramRepository(),
|
||||||
exerciseRepository: _FakeExerciseRepository(),
|
exerciseRepository: _FakeExerciseRepository(),
|
||||||
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
originDeviceId: 'device-1',
|
originDeviceId: 'device-1',
|
||||||
|
|||||||
@ -233,11 +233,83 @@ void main() {
|
|||||||
findsOneWidget,
|
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) {
|
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
|
||||||
return ExerciseUseCases(
|
return ExerciseUseCases(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
|
programRepository: _FakeProgramRepository(),
|
||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
originDeviceId: 'device-1',
|
originDeviceId: 'device-1',
|
||||||
@ -246,17 +318,55 @@ ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
|
|||||||
|
|
||||||
ProgramUseCases _programUseCases(
|
ProgramUseCases _programUseCases(
|
||||||
_FakeProgramRepository programRepository,
|
_FakeProgramRepository programRepository,
|
||||||
_FakeExerciseRepository exerciseRepository,
|
_FakeExerciseRepository exerciseRepository, {
|
||||||
) {
|
_FakeWorkoutTemplateRepository? templateRepository,
|
||||||
|
}) {
|
||||||
return ProgramUseCases(
|
return ProgramUseCases(
|
||||||
programRepository: programRepository,
|
programRepository: programRepository,
|
||||||
exerciseRepository: exerciseRepository,
|
exerciseRepository: exerciseRepository,
|
||||||
|
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
originDeviceId: 'device-1',
|
originDeviceId: 'device-1',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _FakeWorkoutTemplateRepository
|
||||||
|
implements WorkoutTemplateRepository {
|
||||||
|
final templates = <WorkoutTemplate>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WorkoutTemplate?> findById(String id) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<WorkoutTemplate>> listActive() async {
|
||||||
|
return templates
|
||||||
|
.where((template) => template.metadata.deletedAt == null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> replaceComposition(
|
||||||
|
WorkoutTemplate template,
|
||||||
|
DateTime deletedAt,
|
||||||
|
) async {
|
||||||
|
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
|
||||||
|
templates.add(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(WorkoutTemplate template) async {
|
||||||
|
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
|
||||||
|
templates.add(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||||||
|
}
|
||||||
|
|
||||||
EntityMetadata _metadata(String id) {
|
EntityMetadata _metadata(String id) {
|
||||||
return EntityMetadata(
|
return EntityMetadata(
|
||||||
id: id,
|
id: id,
|
||||||
@ -313,6 +423,7 @@ final class _FakeExerciseRepository implements ExerciseRepository {
|
|||||||
|
|
||||||
final class _FakeProgramRepository implements ProgramRepository {
|
final class _FakeProgramRepository implements ProgramRepository {
|
||||||
final programs = <Program>[];
|
final programs = <Program>[];
|
||||||
|
final saved = <Program>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Program?> findById(String id) async {
|
Future<Program?> findById(String id) async {
|
||||||
@ -325,7 +436,11 @@ final class _FakeProgramRepository implements ProgramRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Program>> listActive() async => programs;
|
Future<List<Program>> listActive() async {
|
||||||
|
return programs
|
||||||
|
.where((program) => program.metadata.deletedAt == null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
|
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
|
||||||
@ -335,6 +450,8 @@ final class _FakeProgramRepository implements ProgramRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(Program program) async {
|
Future<void> save(Program program) async {
|
||||||
|
saved.add(program);
|
||||||
|
programs.removeWhere((item) => item.metadata.id == program.metadata.id);
|
||||||
programs.add(program);
|
programs.add(program);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,7 @@ void main() {
|
|||||||
programUseCases: ProgramUseCases(
|
programUseCases: ProgramUseCases(
|
||||||
programRepository: programRepository,
|
programRepository: programRepository,
|
||||||
exerciseRepository: _FakeExerciseRepository(),
|
exerciseRepository: _FakeExerciseRepository(),
|
||||||
|
templateRepository: templateRepository,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
originDeviceId: 'device-1',
|
originDeviceId: 'device-1',
|
||||||
@ -108,6 +109,81 @@ void main() {
|
|||||||
expect(activeRepository.saveCount, 0);
|
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() {
|
WorkoutTemplate _workoutTemplate() {
|
||||||
@ -201,6 +277,7 @@ final class _FakeWorkoutTemplateRepository
|
|||||||
_FakeWorkoutTemplateRepository(this.templates);
|
_FakeWorkoutTemplateRepository(this.templates);
|
||||||
|
|
||||||
final List<WorkoutTemplate> templates;
|
final List<WorkoutTemplate> templates;
|
||||||
|
final saved = <WorkoutTemplate>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<WorkoutTemplate?> findById(String id) async {
|
Future<WorkoutTemplate?> findById(String id) async {
|
||||||
@ -211,16 +288,27 @@ final class _FakeWorkoutTemplateRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<WorkoutTemplate>> listActive() async => templates;
|
Future<List<WorkoutTemplate>> listActive() async {
|
||||||
|
return templates
|
||||||
|
.where((template) => template.metadata.deletedAt == null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> replaceComposition(
|
Future<void> replaceComposition(
|
||||||
WorkoutTemplate template,
|
WorkoutTemplate template,
|
||||||
DateTime deletedAt,
|
DateTime deletedAt,
|
||||||
) async {}
|
) async {
|
||||||
|
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
|
||||||
|
templates.add(template);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(WorkoutTemplate template) async {}
|
Future<void> save(WorkoutTemplate template) async {
|
||||||
|
saved.add(template);
|
||||||
|
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
|
||||||
|
templates.add(template);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||||
|
|||||||
Reference in New Issue
Block a user