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