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

@ -8,6 +8,8 @@ enum WorkoutMeasure { time, reps, score }
enum ScoreInputMode { manual, stopwatch }
enum ExerciseStepType { time, reps }
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
enum SetResultStatus { completed, skipped }
@ -138,6 +140,7 @@ final class Exercise {
this.defaultTargetReps,
this.defaultTargetScore,
this.defaultTargetScoreTimeMs,
List<ExerciseStep> steps = const [],
this.archivedAt,
}) : name = _nonBlank(name, 'Exercise name'),
imageMediaIds = _resolvedExerciseImageMediaIds(
@ -147,7 +150,8 @@ final class Exercise {
iconMediaId = _validatedIconMediaId(
iconMediaId,
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
) {
),
steps = _validatedExerciseSteps(steps) {
_requireAtLeastOneMeasure(
hasTime: hasTimeMeasure,
hasReps: hasRepsMeasure,
@ -193,6 +197,7 @@ final class Exercise {
final int? defaultTargetReps;
final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs;
final List<ExerciseStep> steps;
final DateTime? archivedAt;
Set<WorkoutMeasure> get availableMeasures => {
@ -226,6 +231,7 @@ final class Exercise {
Object? defaultTargetReps = _unchanged,
Object? defaultTargetScore = _unchanged,
Object? defaultTargetScoreTimeMs = _unchanged,
Object? steps = _unchanged,
Object? archivedAt = _unchanged,
}) {
final resolvedImageMediaIds = imageMediaIds == _unchanged
@ -272,6 +278,7 @@ final class Exercise {
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged
? this.defaultTargetScoreTimeMs
: defaultTargetScoreTimeMs as int?,
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
archivedAt: archivedAt == _unchanged
? this.archivedAt
: archivedAt as DateTime?,
@ -279,6 +286,60 @@ final class Exercise {
}
}
final class ExerciseStep {
ExerciseStep({
required String id,
required this.position,
required String name,
required this.type,
required this.defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreLabel,
this.scoreUnit,
this.defaultTargetScore,
this.defaultTargetScoreTimeMs,
}) : id = _nonBlank(id, 'Exercise step id'),
name = _nonBlank(name, 'Exercise step name') {
_requireNonNegative(position, 'Exercise step position');
_requirePositive(defaultTargetValue, 'Exercise step default target value');
_validateExerciseStepScoreShape(
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
}
final String id;
final int position;
final String name;
final ExerciseStepType type;
final int defaultTargetValue;
final bool hasScore;
final ScoreInputMode scoreInputMode;
final String? scoreLabel;
final String? scoreUnit;
final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs;
Map<String, Object?> toSnapshotJson() => {
'id': id,
'position': position,
'name': name,
'type': type.name,
'defaultTargetValue': defaultTargetValue,
'hasScore': hasScore,
'scoreInputMode': scoreInputMode.name,
'scoreLabel': scoreLabel,
'scoreUnit': scoreUnit,
'defaultTargetScore': defaultTargetScore,
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
};
}
const Object _unchanged = Object();
final class Program {
@ -322,6 +383,7 @@ final class ProgramExercise {
this.exerciseImageMediaIdSnapshot,
List<String> exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
List<ExerciseStep> exerciseStepsSnapshot = const [],
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
@ -347,7 +409,8 @@ final class ProgramExercise {
exerciseImageMediaIdSnapshot != null
? [exerciseImageMediaIdSnapshot]
: exerciseImageMediaIdsSnapshot,
) {
),
exerciseStepsSnapshot = _validatedExerciseSteps(exerciseStepsSnapshot) {
_requireNonNegative(position, 'Position');
_requirePositive(setsCount, 'Sets count');
_requireAtLeastOneMeasure(
@ -386,6 +449,7 @@ final class ProgramExercise {
final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
@ -432,6 +496,7 @@ final class ProgramExercise {
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseStepsSnapshot: exercise.steps,
exerciseArchivedSnapshot: exercise.archivedAt != null,
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
@ -461,6 +526,9 @@ final class ProgramExercise {
'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot,
'exerciseStepsSnapshot': exerciseStepsSnapshot
.map((step) => step.toSnapshotJson())
.toList(),
'exerciseArchivedSnapshot': exerciseArchivedSnapshot,
'availableTimeSnapshot': availableTimeSnapshot,
'availableRepsSnapshot': availableRepsSnapshot,
@ -998,6 +1066,24 @@ String? _validatedIconMediaId(String? iconMediaId, List<String> imageMediaIds) {
return normalized;
}
List<ExerciseStep> _validatedExerciseSteps(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++) {
if (steps[index].position != index) {
throw const DomainException(
'Exercise step positions must be contiguous and ordered.',
);
}
}
final ids = steps.map((step) => _nonBlank(step.id, 'Exercise step id'));
if (ids.toSet().length != steps.length) {
throw const DomainException('Exercise step ids must be unique.');
}
return List.unmodifiable(steps);
}
void _requireAtLeastOneMeasure({
required bool hasTime,
required bool hasReps,
@ -1063,6 +1149,58 @@ void _requireScoreTargetShape({
}
}
void _validateExerciseStepScoreShape({
required bool hasScore,
required ScoreInputMode scoreInputMode,
required String? scoreLabel,
required String? scoreUnit,
required double? defaultTargetScore,
required int? defaultTargetScoreTimeMs,
}) {
if (!hasScore) {
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null ||
defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Disabled exercise step score must not define score values.',
);
}
return;
}
if (defaultTargetScore != null && defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual and stopwatch step score targets cannot both be set.',
);
}
switch (scoreInputMode) {
case ScoreInputMode.manual:
_nonBlank(scoreLabel, 'Exercise step score label');
_nonBlank(scoreUnit, 'Exercise step score unit');
_requireNullableNonNegativeDouble(
defaultTargetScore,
'Exercise step default target score',
);
if (defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual exercise step score cannot define stopwatch target.',
);
}
case ScoreInputMode.stopwatch:
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null) {
throw const DomainException(
'Stopwatch exercise step score cannot define manual score values.',
);
}
_requireNullablePositive(
defaultTargetScoreTimeMs,
'Exercise step default target score time ms',
);
}
}
void _requireScoreResultShape({
required ScoreInputMode scoreInputMode,
required double? actualScore,