feat(exercice): modèle domain + Drift pour exercices à étapes (ticket #56)

Étend le modèle Drift (tables.dart, app_database.dart/.g.dart,
migration schemaVersion 7→8) et la couche application (entités, use
cases, repositories) pour supporter des exercices composés de
plusieurs étapes. flutter pub get OK, build_runner OK, flutter
analyze propre (mêmes 8 infos préexistantes), 87/87 tests verts,
build APK debug validé. Premier ticket du chantier "Exercice à
plusieurs étapes" (#54).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 19:32:24 +02:00
parent 73d01371d4
commit b1182db32e
8 changed files with 2958 additions and 6 deletions

View File

@ -37,7 +37,9 @@ final class ExerciseUseCases {
int? defaultTargetReps,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
List<ExerciseStep> steps = const [],
}) async {
_validateExerciseSteps(steps);
_validateExerciseDefaultTargets(
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
@ -97,6 +99,7 @@ final class ExerciseUseCases {
Object? defaultTargetReps = _useCaseUnchanged,
Object? defaultTargetScore = _useCaseUnchanged,
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
Object? steps = _useCaseUnchanged,
}) async {
final exercise = await repository.findById(id);
if (exercise == null) {
@ -126,6 +129,10 @@ final class ExerciseUseCases {
defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
);
final resolvedSteps = steps == _useCaseUnchanged
? exercise.steps
: steps as List<ExerciseStep>;
_validateExerciseSteps(resolvedSteps);
final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
: imageMediaIds as List<String>;
@ -152,6 +159,7 @@ final class ExerciseUseCases {
defaultTargetReps: resolvedDefaultTargetReps,
defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
steps: resolvedSteps,
);
await repository.save(updated);
return updated;
@ -475,6 +483,7 @@ final class ProgramUseCases {
exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: input.exerciseStepsSnapshot,
exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
availableTimeSnapshot: input.availableTimeSnapshot,
availableRepsSnapshot: input.availableRepsSnapshot,
@ -592,6 +601,7 @@ final class ProgramExerciseConfig {
this.exerciseImageMediaIdSnapshot,
this.exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
this.exerciseStepsSnapshot = const [],
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
@ -615,6 +625,7 @@ final class ProgramExerciseConfig {
final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
@ -1560,6 +1571,7 @@ List<ProgramExercise> _repositionProgramExercises(
exercises[index].exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot:
exercises[index].exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exercises[index].exerciseStepsSnapshot,
exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot,
availableTimeSnapshot: exercises[index].availableTimeSnapshot,
availableRepsSnapshot: exercises[index].availableRepsSnapshot,
@ -1625,6 +1637,82 @@ bool _sameImageSet(List<String> left, List<String> right) {
return left.toSet().containsAll(right) && right.toSet().containsAll(left);
}
void _validateExerciseSteps(List<ExerciseStep> steps) {
if (steps.length > 8) {
throw const DomainException('An exercise cannot have more than 8 steps.');
}
for (var index = 0; index < steps.length; index++) {
final step = steps[index];
if (step.position != index) {
throw const DomainException(
'Exercise step positions must be contiguous and ordered.',
);
}
if (step.name.trim().isEmpty) {
throw const DomainException('Exercise step name must not be blank.');
}
if (step.defaultTargetValue <= 0) {
throw const DomainException(
'Exercise step default target value must be positive.',
);
}
if (!step.hasScore) {
if (step.scoreLabel != null ||
step.scoreUnit != null ||
step.defaultTargetScore != null ||
step.defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Disabled exercise step score must not define score values.',
);
}
continue;
}
if (step.defaultTargetScore != null &&
step.defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual and stopwatch step score targets cannot both be set.',
);
}
switch (step.scoreInputMode) {
case ScoreInputMode.manual:
if (step.scoreLabel == null || step.scoreLabel!.trim().isEmpty) {
throw const DomainException(
'Exercise step score label must not be blank.',
);
}
if (step.scoreUnit == null || step.scoreUnit!.trim().isEmpty) {
throw const DomainException(
'Exercise step score unit must not be blank.',
);
}
if (step.defaultTargetScore != null && step.defaultTargetScore! < 0) {
throw const DomainException(
'Exercise step default target score must not be negative.',
);
}
if (step.defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual exercise step score cannot define stopwatch target.',
);
}
case ScoreInputMode.stopwatch:
if (step.scoreLabel != null ||
step.scoreUnit != null ||
step.defaultTargetScore != null) {
throw const DomainException(
'Stopwatch exercise step score cannot define manual score values.',
);
}
if (step.defaultTargetScoreTimeMs != null &&
step.defaultTargetScoreTimeMs! <= 0) {
throw const DomainException(
'Exercise step default target score time ms must be positive.',
);
}
}
}
}
void _validateExerciseDefaultTargets({
required bool hasTimeMeasure,
required bool hasRepsMeasure,