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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user