feat(exercice): exécution persistante des étapes et éditeur d'étapes (tickets #58, #59)

Ajoute la persistance de l'exécution par étapes avec résultats par
passage (ports, use cases, entités, modèle Drift avec migration) et
l'éditeur d'exercice avec séquence d'étapes sur
exercise_library_screen.dart. Développés dans le même worktree
partagé par DevBackend et DevFrontend ; commit combiné plutôt que
séparé car les fakes de repository de test corrigés (nouvelles
interfaces #58) touchent plusieurs écrans sans lien avec l'éditeur
lui-même (historique, accueil, exécution, séance-modèle). Corrige au
passage un ListTile sans ancêtre Material dans le nouvel éditeur
d'étapes (Material(type: MaterialType.transparency)). flutter pub get
OK, build_runner OK, analyze propre (mêmes infos préexistantes),
93/93 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 19:52:43 +02:00
parent 4731a257a1
commit ebba22db56
14 changed files with 11371 additions and 2 deletions

View File

@ -16,6 +16,15 @@ enum SetResultStatus { completed, skipped }
enum ActiveScoreStopwatchStatus { running, stopped }
enum ActiveExerciseStepProgressStatus {
notStarted,
waitingManual,
runningTimer,
pausedTimer,
stoppedTimer,
sequenceComplete,
}
final class DomainException implements Exception {
const DomainException(this.message);
@ -916,6 +925,166 @@ final class ActiveRestState {
}
}
final class ActiveExerciseStepProgressState {
ActiveExerciseStepProgressState({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.currentPassageIndex,
required this.currentStepIndex,
required this.currentStepSnapshotId,
required this.status,
this.startedAt,
required this.accumulatedMs,
required this.lastTransitionAt,
}) {
_requireNonNegative(programIndex, 'Program index');
_requireNonNegative(exerciseIndex, 'Exercise index');
_requireNonNegative(setIndex, 'Set index');
_requireNonNegative(currentPassageIndex, 'Current passage index');
_requireNonNegative(currentStepIndex, 'Current step index');
_nonBlank(currentStepSnapshotId, 'Current step snapshot id');
_requireNonNegative(accumulatedMs, 'Accumulated milliseconds');
if (status == ActiveExerciseStepProgressStatus.runningTimer &&
startedAt == null) {
throw const DomainException(
'Running exercise step timer requires a start timestamp.',
);
}
}
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final int currentPassageIndex;
final int currentStepIndex;
final String currentStepSnapshotId;
final ActiveExerciseStepProgressStatus status;
final DateTime? startedAt;
final int accumulatedMs;
final DateTime lastTransitionAt;
int elapsedMillisecondsAt(DateTime now) {
if (status != ActiveExerciseStepProgressStatus.runningTimer ||
startedAt == null) {
return accumulatedMs;
}
return accumulatedMs + now.difference(startedAt!).inMilliseconds;
}
ActiveExerciseStepProgressState copyWith({
EntityMetadata? metadata,
int? currentPassageIndex,
int? currentStepIndex,
String? currentStepSnapshotId,
ActiveExerciseStepProgressStatus? status,
Object? startedAt = _unchanged,
int? accumulatedMs,
DateTime? lastTransitionAt,
}) {
return ActiveExerciseStepProgressState(
metadata: metadata ?? this.metadata,
activeWorkoutSessionId: activeWorkoutSessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
currentPassageIndex: currentPassageIndex ?? this.currentPassageIndex,
currentStepIndex: currentStepIndex ?? this.currentStepIndex,
currentStepSnapshotId:
currentStepSnapshotId ?? this.currentStepSnapshotId,
status: status ?? this.status,
startedAt: startedAt == _unchanged ? this.startedAt : startedAt as DateTime?,
accumulatedMs: accumulatedMs ?? this.accumulatedMs,
lastTransitionAt: lastTransitionAt ?? this.lastTransitionAt,
);
}
}
final class ActiveExerciseStepResult {
ActiveExerciseStepResult({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.passageIndex,
required this.stepIndex,
required this.stepSnapshotId,
required this.stepNameSnapshot,
required this.stepTypeSnapshot,
required this.targetValueSnapshot,
required this.hasScoreSnapshot,
this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.targetScoreSnapshot,
this.targetScoreTimeMsSnapshot,
required this.status,
this.startedAt,
this.completedAt,
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.actualScoreTimeMs,
this.note,
}) {
_validateExerciseStepResult(
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
stepTypeSnapshot: stepTypeSnapshot,
targetValueSnapshot: targetValueSnapshot,
hasScoreSnapshot: hasScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
targetScoreSnapshot: targetScoreSnapshot,
targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot,
status: status,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
}
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final int passageIndex;
final int stepIndex;
final String stepSnapshotId;
final String stepNameSnapshot;
final ExerciseStepType stepTypeSnapshot;
final int targetValueSnapshot;
final bool hasScoreSnapshot;
final ScoreInputMode? scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final double? targetScoreSnapshot;
final int? targetScoreTimeMsSnapshot;
final SetResultStatus status;
final DateTime? startedAt;
final DateTime? completedAt;
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final int? actualScoreTimeMs;
final String? note;
}
final class WorkoutHistory {
const WorkoutHistory({
required this.metadata,
@ -928,6 +1097,7 @@ final class WorkoutHistory {
required this.completed,
required this.historySnapshotJson,
this.results = const [],
this.stepResults = const [],
});
final EntityMetadata metadata;
@ -940,6 +1110,7 @@ final class WorkoutHistory {
final bool completed;
final String historySnapshotJson;
final List<WorkoutHistorySetResult> results;
final List<WorkoutHistoryStepResult> stepResults;
}
final class WorkoutHistorySetResult {
@ -1021,6 +1192,87 @@ final class WorkoutHistorySetResult {
final SetResultStatus status;
}
final class WorkoutHistoryStepResult {
WorkoutHistoryStepResult({
required this.metadata,
required this.workoutHistoryId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.passageIndex,
required this.stepIndex,
required this.stepSnapshotId,
required this.stepNameSnapshot,
required this.stepTypeSnapshot,
required this.targetValueSnapshot,
required this.hasScoreSnapshot,
this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.targetScoreSnapshot,
this.targetScoreTimeMsSnapshot,
required this.status,
this.startedAt,
this.completedAt,
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.actualScoreTimeMs,
this.note,
}) {
_validateExerciseStepResult(
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
stepTypeSnapshot: stepTypeSnapshot,
targetValueSnapshot: targetValueSnapshot,
hasScoreSnapshot: hasScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
targetScoreSnapshot: targetScoreSnapshot,
targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot,
status: status,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
}
final EntityMetadata metadata;
final String workoutHistoryId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final int passageIndex;
final int stepIndex;
final String stepSnapshotId;
final String stepNameSnapshot;
final ExerciseStepType stepTypeSnapshot;
final int targetValueSnapshot;
final bool hasScoreSnapshot;
final ScoreInputMode? scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final double? targetScoreSnapshot;
final int? targetScoreTimeMsSnapshot;
final SetResultStatus status;
final DateTime? startedAt;
final DateTime? completedAt;
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final int? actualScoreTimeMs;
final String? note;
}
String _nonBlank(String? value, String label) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
@ -1222,3 +1474,67 @@ void _requireScoreResultShape({
);
}
}
void _validateExerciseStepResult({
required int programIndex,
required int exerciseIndex,
required int setIndex,
required int passageIndex,
required int stepIndex,
required ExerciseStepType stepTypeSnapshot,
required int targetValueSnapshot,
required bool hasScoreSnapshot,
required ScoreInputMode? scoreInputModeSnapshot,
required String? scoreLabelSnapshot,
required String? scoreUnitSnapshot,
required double? targetScoreSnapshot,
required int? targetScoreTimeMsSnapshot,
required SetResultStatus status,
required int? actualTimeMs,
required int? actualReps,
required double? actualScore,
required int? actualScoreTimeMs,
}) {
_requireNonNegative(programIndex, 'Program index');
_requireNonNegative(exerciseIndex, 'Exercise index');
_requireNonNegative(setIndex, 'Set index');
_requireNonNegative(passageIndex, 'Passage index');
_requireNonNegative(stepIndex, 'Step index');
_requirePositive(targetValueSnapshot, 'Step target value snapshot');
_requireNullableNonNegative(actualTimeMs, 'Actual time milliseconds');
_requireNullableNonNegative(actualReps, 'Actual reps');
_requireNullableNonNegativeDouble(actualScore, 'Actual score');
_requireNullableNonNegative(actualScoreTimeMs, 'Actual score time ms');
if (status == SetResultStatus.skipped &&
(actualTimeMs != null ||
actualReps != null ||
actualScore != null ||
actualScoreTimeMs != null)) {
throw const DomainException('Skipped step results cannot have values.');
}
if (actualTimeMs != null && stepTypeSnapshot != ExerciseStepType.time) {
throw const DomainException('Actual time requires a time step.');
}
if (actualReps != null && stepTypeSnapshot != ExerciseStepType.reps) {
throw const DomainException('Actual reps requires a reps step.');
}
final mode = scoreInputModeSnapshot ?? ScoreInputMode.manual;
_requireScoreTargetShape(
scoreEnabled: hasScoreSnapshot,
scoreInputMode: mode,
targetScore: targetScoreSnapshot,
targetScoreTimeMs: targetScoreTimeMsSnapshot,
);
_requireScoreResultShape(
scoreInputMode: mode,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
if (!hasScoreSnapshot && (actualScore != null || actualScoreTimeMs != null)) {
throw const DomainException('Step score result requires active score.');
}
if (actualScore != null) {
_nonBlank(scoreLabelSnapshot, 'Step score label snapshot');
_nonBlank(scoreUnitSnapshot, 'Step score unit snapshot');
}
}