feat(core): consolide modele donnees stats par scope, types metier d'exercice et correctif contrainte active_exercise_step_progress_states

Couche domaine/application/infra locale partagee par #179 (stockage stats par etape/serie/exercice/seance), #182 (correctif contrainte UNIQUE active_exercise_step_progress_states) et #183 (modele/persistance types metier d'exercice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:55:52 +02:00
parent 7b5801f959
commit fdaa518fa9
7 changed files with 1036 additions and 80 deletions

View File

@ -21,6 +21,18 @@ enum ExerciseCategory {
uncategorized,
}
enum BusinessExerciseType {
shoot,
freeThrows,
dribble,
finishing,
conditioning,
defense,
mobility,
highIntensity,
recovery,
}
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
enum SetResultStatus { completed, skipped }
@ -333,6 +345,7 @@ final class Exercise {
this.defaultTargetScoreTimeMs,
this.autoStartNextTimedStep = true,
this.category = ExerciseCategory.uncategorized,
List<BusinessExerciseType> businessTypes = const [],
this.isExample = false,
List<String> tags = const [],
List<ExerciseStep> steps = const [],
@ -347,6 +360,7 @@ final class Exercise {
iconMediaId,
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
),
businessTypes = _validatedBusinessExerciseTypes(businessTypes),
steps = _validatedExerciseSteps(steps) {
_requireAtLeastOneMeasure(
hasTime: hasTimeMeasure,
@ -406,6 +420,7 @@ final class Exercise {
final int? defaultTargetScoreTimeMs;
final bool autoStartNextTimedStep;
final ExerciseCategory category;
final List<BusinessExerciseType> businessTypes;
final bool isExample;
final List<String> tags;
final List<ExerciseStep> steps;
@ -420,6 +435,10 @@ final class Exercise {
String? get imageMediaId =>
imageMediaIds.isEmpty ? null : imageMediaIds.first;
List<BusinessExerciseType> get effectiveBusinessTypes => businessTypes.isEmpty
? _businessTypesFromCategory(category)
: businessTypes;
Exercise archive(DateTime now) {
return copyWith(archivedAt: now, metadata: metadata.touch(now));
}
@ -444,6 +463,7 @@ final class Exercise {
Object? defaultTargetScoreTimeMs = _unchanged,
bool? autoStartNextTimedStep,
ExerciseCategory? category,
Object? businessTypes = _unchanged,
bool? isExample,
Object? tags = _unchanged,
Object? steps = _unchanged,
@ -496,6 +516,9 @@ final class Exercise {
autoStartNextTimedStep:
autoStartNextTimedStep ?? this.autoStartNextTimedStep,
category: category ?? this.category,
businessTypes: businessTypes == _unchanged
? this.businessTypes
: businessTypes as List<BusinessExerciseType>,
isExample: isExample ?? this.isExample,
tags: tags == _unchanged ? this.tags : tags as List<String>,
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
@ -1675,6 +1698,68 @@ final class WorkoutTelemetryAggregate {
final double? totalCaloriesKcal;
}
final class WorkoutTelemetryGraphPoint {
WorkoutTelemetryGraphPoint({
required this.capturedAt,
required this.elapsedMs,
this.heartRateBpm,
this.distanceMeters,
this.caloriesKcal,
required this.startsAfterGap,
}) {
_requireNonNegative(elapsedMs, 'Telemetry graph elapsed ms');
_requireNullablePositive(heartRateBpm, 'Heart rate bpm');
_requireNullableNonNegativeDouble(distanceMeters, 'Distance meters');
_requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal');
if (heartRateBpm == null &&
distanceMeters == null &&
caloriesKcal == null) {
throw const DomainException(
'Telemetry graph point must contain at least one metric.',
);
}
}
final DateTime capturedAt;
final int elapsedMs;
final int? heartRateBpm;
final double? distanceMeters;
final double? caloriesKcal;
final bool startsAfterGap;
}
final class WorkoutTelemetryGraphSeries {
WorkoutTelemetryGraphSeries({
required this.scope,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
required this.points,
this.minHeartRateBpm,
this.maxHeartRateBpm,
}) {
_requireNullableNonNegative(programIndex, 'Program index');
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
_requireNullableNonNegative(setIndex, 'Set index');
_requireNullableNonNegative(passageIndex, 'Passage index');
_requireNullableNonNegative(stepIndex, 'Step index');
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
}
final WorkoutTelemetryAggregateScope scope;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final List<WorkoutTelemetryGraphPoint> points;
final int? minHeartRateBpm;
final int? maxHeartRateBpm;
}
final class WorkoutHistorySetResult {
WorkoutHistorySetResult({
required this.metadata,
@ -1924,6 +2009,33 @@ List<ExerciseStep> _validatedExerciseSteps(List<ExerciseStep> steps) {
return List.unmodifiable(steps);
}
List<BusinessExerciseType> _validatedBusinessExerciseTypes(
List<BusinessExerciseType> types,
) {
final unique = <BusinessExerciseType>[];
for (final type in types) {
if (!unique.contains(type)) {
unique.add(type);
}
}
return List.unmodifiable(unique);
}
List<BusinessExerciseType> _businessTypesFromCategory(
ExerciseCategory category,
) {
return switch (category) {
ExerciseCategory.shoot => const [BusinessExerciseType.shoot],
ExerciseCategory.freeThrows => const [BusinessExerciseType.freeThrows],
ExerciseCategory.dribble => const [BusinessExerciseType.dribble],
ExerciseCategory.finishing => const [BusinessExerciseType.finishing],
ExerciseCategory.conditioning => const [BusinessExerciseType.conditioning],
ExerciseCategory.defense => const [BusinessExerciseType.defense],
ExerciseCategory.mobility => const [BusinessExerciseType.mobility],
ExerciseCategory.uncategorized => const [],
};
}
void _requireAtLeastOneMeasure({
required bool hasTime,
required bool hasReps,