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

@ -81,6 +81,10 @@ abstract interface class ActiveSessionRepository {
Future<void> saveSetResult(ActiveSetResult result);
Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
);
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result);
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
@ -95,7 +99,19 @@ abstract interface class ActiveSessionRepository {
required int exerciseIndex,
required int setIndex,
});
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<List<ActiveSetResult>> listSetResults(String sessionId);
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
);
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
);
Future<List<ActiveRestState>> listRestStates(String sessionId);
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
@ -107,6 +123,7 @@ abstract interface class WorkoutHistoryRepository {
Future<List<WorkoutHistory>> listActive();
Future<void> save(WorkoutHistory history);
Future<void> saveSetResult(WorkoutHistorySetResult result);
Future<void> saveStepResult(WorkoutHistoryStepResult result);
Future<void> delete(String id, DateTime deletedAt);
}

View File

@ -68,6 +68,7 @@ final class ExerciseUseCases {
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
);
await repository.save(exercise);
return exercise;
@ -962,6 +963,23 @@ final class ActiveWorkoutSessionUseCases {
);
await sessionRepository.saveScoreStopwatchState(stopped);
}
final runningStepTimers =
(await sessionRepository.listExerciseStepProgressStates(sessionId))
.where(
(state) =>
state.status ==
ActiveExerciseStepProgressStatus.runningTimer,
);
for (final state in runningStepTimers) {
final pausedStep = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.pausedTimer,
accumulatedMs: state.elapsedMillisecondsAt(now),
startedAt: null,
lastTransitionAt: now,
);
await sessionRepository.saveExerciseStepProgressState(pausedStep);
}
final paused = session.pause(now);
await sessionRepository.save(paused);
return paused;
@ -1441,6 +1459,555 @@ final class ActiveWorkoutSessionUseCases {
}
}
final class ActiveExerciseStepProgressView {
const ActiveExerciseStepProgressView({
required this.state,
required this.steps,
required this.expectedPassages,
required this.results,
});
final ActiveExerciseStepProgressState state;
final List<ExerciseStep> steps;
final int expectedPassages;
final List<ActiveExerciseStepResult> results;
ExerciseStep? get currentStep =>
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
? null
: steps[state.currentStepIndex];
}
final class ActiveExerciseStepUseCases {
const ActiveExerciseStepUseCases({
required this.sessionRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ActiveSessionRepository sessionRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<ActiveExerciseStepProgressView> startOrResumeProgress({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final existing = await sessionRepository.findExerciseStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final state = existing ??
_initialStepProgressState(
sessionId: sessionId,
context: context,
now: now,
);
if (existing == null) {
await sessionRepository.saveExerciseStepProgressState(state);
}
final advanced = await _autoAdvanceElapsedTimers(context, state, now);
return ActiveExerciseStepProgressView(
state: advanced,
steps: context.steps,
expectedPassages: context.expectedPassages,
results: await _stepResultsForPosition(context),
);
}
Future<ActiveExerciseStepProgressView> readProgress({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) => startOrResumeProgress(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
Future<ActiveExerciseStepProgressState> startTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final view = await startOrResumeProgress(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final step = view.currentStep;
if (step == null || step.type != ExerciseStepType.time) {
throw const DomainException('Current exercise step is not timed.');
}
if (view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
return view.state;
}
final now = clock.now();
final running = view.state.copyWith(
metadata: view.state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
lastTransitionAt: now,
);
await sessionRepository.saveExerciseStepProgressState(running);
return running;
}
Future<ActiveExerciseStepProgressState> pauseTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state.status != ActiveExerciseStepProgressStatus.runningTimer) {
throw const DomainException('Exercise step timer is not running.');
}
final now = clock.now();
final paused = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.pausedTimer,
startedAt: null,
accumulatedMs: state.elapsedMillisecondsAt(now),
lastTransitionAt: now,
);
await sessionRepository.saveExerciseStepProgressState(paused);
return paused;
}
Future<ActiveExerciseStepProgressState> resumeTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return startTimer(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
int elapsedMilliseconds(ActiveExerciseStepProgressState state) {
return state.elapsedMillisecondsAt(clock.now());
}
int remainingMilliseconds({
required ActiveExerciseStepProgressState state,
required ExerciseStep step,
}) {
final targetMs = step.defaultTargetValue * 1000;
final remaining = targetMs - elapsedMilliseconds(state);
return remaining < 0 ? 0 : remaining;
}
Future<ActiveExerciseStepResult> completeCurrentStep({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
String? note,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final state = await _autoAdvanceElapsedTimers(
context,
await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
),
now,
);
final step = _currentStep(context, state);
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
throw const DomainException('Exercise step sequence is complete.');
}
final completed = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.completed,
now: now,
actualTimeMs: step.type == ExerciseStepType.time
? state.elapsedMillisecondsAt(now)
: null,
actualReps: step.type == ExerciseStepType.reps
? actualReps ?? step.defaultTargetValue
: null,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
note: note,
);
await sessionRepository.saveExerciseStepResult(completed);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now),
);
return completed;
}
Future<ActiveExerciseStepResult> skipCurrentStep({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final step = _currentStep(context, state);
final skipped = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.skipped,
now: now,
);
await sessionRepository.saveExerciseStepResult(skipped);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now),
);
return skipped;
}
Future<List<ActiveExerciseStepResult>> skipCurrentPassage({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
var state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final skipped = <ActiveExerciseStepResult>[];
final passage = state.currentPassageIndex;
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete &&
state.currentPassageIndex == passage) {
final step = _currentStep(context, state);
final result = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.skipped,
now: now,
);
skipped.add(result);
await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now);
}
await sessionRepository.saveExerciseStepProgressState(state);
return skipped;
}
Future<List<ActiveExerciseStepResult>> skipSequence({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
var state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final skipped = <ActiveExerciseStepResult>[];
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete) {
final step = _currentStep(context, state);
final result = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.skipped,
now: now,
);
skipped.add(result);
await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now);
}
await sessionRepository.saveExerciseStepProgressState(state);
return skipped;
}
Future<ActiveExerciseStepProgressState> _requiredStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await sessionRepository.findExerciseStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
throw const DomainException('Exercise step progress state not found.');
}
return state;
}
Future<_StepSequenceContext> _stepContext({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {
throw const DomainException('Active workout session not found.');
}
final snapshot = _findExerciseSnapshot(
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
);
if (snapshot == null || snapshot.steps.isEmpty) {
throw const DomainException('Exercise step sequence not found.');
}
return _StepSequenceContext(
sessionId: sessionId,
programSnapshotId: snapshot.programSnapshotId,
exerciseSnapshotId: snapshot.exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
steps: snapshot.steps,
expectedPassages: snapshot.repsEnabled
? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
: 1,
);
}
ActiveExerciseStepProgressState _initialStepProgressState({
required String sessionId,
required _StepSequenceContext context,
required DateTime now,
}) {
final firstStep = context.steps.first;
return ActiveExerciseStepProgressState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programIndex: context.programIndex,
exerciseIndex: context.exerciseIndex,
setIndex: context.setIndex,
currentPassageIndex: 0,
currentStepIndex: 0,
currentStepSnapshotId: firstStep.id,
status: firstStep.type == ExerciseStepType.time
? ActiveExerciseStepProgressStatus.stoppedTimer
: ActiveExerciseStepProgressStatus.waitingManual,
accumulatedMs: 0,
lastTransitionAt: now,
);
}
Future<ActiveExerciseStepProgressState> _autoAdvanceElapsedTimers(
_StepSequenceContext context,
ActiveExerciseStepProgressState state,
DateTime now,
) async {
var current = state;
while (current.status == ActiveExerciseStepProgressStatus.runningTimer) {
final step = _currentStep(context, current);
if (step.type != ExerciseStepType.time) {
break;
}
final targetMs = step.defaultTargetValue * 1000;
final elapsed = current.elapsedMillisecondsAt(now);
if (elapsed < targetMs) {
break;
}
final result = _stepResult(
context: context,
state: current,
step: step,
status: SetResultStatus.completed,
now: now,
actualTimeMs: targetMs,
);
await sessionRepository.saveExerciseStepResult(result);
final overflowMs = elapsed - targetMs;
current = _advanceState(
context,
current,
now,
runningTimerOverflowMs: overflowMs,
);
await sessionRepository.saveExerciseStepProgressState(current);
}
return current;
}
ActiveExerciseStepProgressState _advanceState(
_StepSequenceContext context,
ActiveExerciseStepProgressState state,
DateTime now, {
int runningTimerOverflowMs = 0,
}) {
var nextPassage = state.currentPassageIndex;
var nextStep = state.currentStepIndex + 1;
if (nextStep >= context.steps.length) {
nextStep = 0;
nextPassage++;
}
if (nextPassage >= context.expectedPassages) {
return state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.sequenceComplete,
startedAt: null,
accumulatedMs: 0,
lastTransitionAt: now,
);
}
final step = context.steps[nextStep];
final status = step.type == ExerciseStepType.time
? runningTimerOverflowMs > 0
? ActiveExerciseStepProgressStatus.runningTimer
: ActiveExerciseStepProgressStatus.stoppedTimer
: ActiveExerciseStepProgressStatus.waitingManual;
return state.copyWith(
metadata: state.metadata.touch(now),
currentPassageIndex: nextPassage,
currentStepIndex: nextStep,
currentStepSnapshotId: step.id,
status: status,
startedAt: status == ActiveExerciseStepProgressStatus.runningTimer
? now.subtract(Duration(milliseconds: runningTimerOverflowMs))
: null,
accumulatedMs: 0,
lastTransitionAt: now,
);
}
ExerciseStep _currentStep(
_StepSequenceContext context,
ActiveExerciseStepProgressState state,
) {
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
throw const DomainException('Exercise step sequence is complete.');
}
return context.steps[state.currentStepIndex];
}
Future<List<ActiveExerciseStepResult>> _stepResultsForPosition(
_StepSequenceContext context,
) async {
return (await sessionRepository.listExerciseStepResults(context.sessionId))
.where(
(result) =>
result.programIndex == context.programIndex &&
result.exerciseIndex == context.exerciseIndex &&
result.setIndex == context.setIndex,
)
.toList();
}
ActiveExerciseStepResult _stepResult({
required _StepSequenceContext context,
required ActiveExerciseStepProgressState state,
required ExerciseStep step,
required SetResultStatus status,
required DateTime now,
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
String? note,
}) {
return ActiveExerciseStepResult(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: context.sessionId,
programSnapshotId: context.programSnapshotId,
exerciseSnapshotId: context.exerciseSnapshotId,
programIndex: context.programIndex,
exerciseIndex: context.exerciseIndex,
setIndex: context.setIndex,
passageIndex: state.currentPassageIndex,
stepIndex: state.currentStepIndex,
stepSnapshotId: step.id,
stepNameSnapshot: step.name,
stepTypeSnapshot: step.type,
targetValueSnapshot: step.defaultTargetValue,
hasScoreSnapshot: step.hasScore,
scoreInputModeSnapshot: step.hasScore ? step.scoreInputMode : null,
scoreLabelSnapshot: step.scoreLabel,
scoreUnitSnapshot: step.scoreUnit,
targetScoreSnapshot: step.defaultTargetScore,
targetScoreTimeMsSnapshot: step.defaultTargetScoreTimeMs,
status: status,
startedAt: state.startedAt,
completedAt: status == SetResultStatus.completed ? now : null,
actualTimeMs: status == SetResultStatus.completed ? actualTimeMs : null,
actualReps: status == SetResultStatus.completed ? actualReps : null,
actualScore: status == SetResultStatus.completed ? actualScore : null,
actualScoreTimeMs: status == SetResultStatus.completed
? actualScoreTimeMs
: null,
note: note,
);
}
}
final class CloseWorkoutSessionUseCase {
const CloseWorkoutSessionUseCase({
required this.sessionRepository,
@ -1467,6 +2034,9 @@ final class CloseWorkoutSessionUseCase {
}
final now = clock.now();
final results = await sessionRepository.listSetResults(sessionId);
final stepResults = await sessionRepository.listExerciseStepResults(
sessionId,
);
final historyId = ids.newId();
final historyResults = _historyResultsFromActiveResults(
historyId: historyId,
@ -1476,6 +2046,13 @@ final class CloseWorkoutSessionUseCase {
ids: ids,
originDeviceId: originDeviceId,
);
final historyStepResults = _historyStepResultsFromActiveResults(
historyId: historyId,
results: stepResults,
now: now,
ids: ids,
originDeviceId: originDeviceId,
);
final history = WorkoutHistory(
metadata: EntityMetadata(
id: historyId,
@ -1512,8 +2089,38 @@ final class CloseWorkoutSessionUseCase {
},
)
.toList(),
'stepResults': stepResults
.map(
(result) => {
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'passageIndex': result.passageIndex,
'stepIndex': result.stepIndex,
'stepSnapshotId': result.stepSnapshotId,
'stepNameSnapshot': result.stepNameSnapshot,
'stepTypeSnapshot': result.stepTypeSnapshot.name,
'targetValueSnapshot': result.targetValueSnapshot,
'hasScoreSnapshot': result.hasScoreSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'targetScoreSnapshot': result.targetScoreSnapshot,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
'status': result.status.name,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'note': result.note,
},
)
.toList(),
}),
results: historyResults,
stepResults: historyStepResults,
);
await historyRepository.save(history);
return history;
@ -1867,6 +2474,53 @@ _SetPositionSnapshot? _findSetSnapshot({
return null;
}
_ResolvedExerciseSnapshot? _findExerciseSnapshot({
required String resolvedTemplateSnapshotJson,
required int programIndex,
required int exerciseIndex,
}) {
final decoded =
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
if (programIndex < 0 || programIndex >= programs.length) {
return null;
}
final program = programs[programIndex] as Map<String, dynamic>;
final programSnapshotId = program['id'] as String? ?? 'program-$programIndex';
final programName = program['programNameSnapshot'] as String? ?? '';
final programSnapshotJson = program['programSnapshotJson'] as String?;
if (programSnapshotJson == null) {
return null;
}
final programSnapshot =
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises = (programSnapshot['exercises'] as List<dynamic>? ?? const []);
if (exerciseIndex < 0 || exerciseIndex >= exercises.length) {
return null;
}
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex';
return _ResolvedExerciseSnapshot(
programSnapshotId: programSnapshotId,
exerciseSnapshotId: id,
programNameSnapshot: programName,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
timeEnabled: exercise['timeEnabled'] == true,
repsEnabled: exercise['repsEnabled'] == true,
scoreEnabled: exercise['scoreEnabled'] == true,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
);
}
List<_SetPositionSnapshot> _listSetSnapshots(
String resolvedTemplateSnapshotJson,
) {
@ -1938,6 +2592,28 @@ final class _SetPositionSnapshot {
final ScoreInputMode scoreInputModeSnapshot;
}
final class _StepSequenceContext {
const _StepSequenceContext({
required this.sessionId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.steps,
required this.expectedPassages,
});
final String sessionId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final List<ExerciseStep> steps;
final int expectedPassages;
}
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
required String historyId,
required List<ActiveSetResult> results,
@ -2010,6 +2686,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
continue;
}
snapshots[id] = _ResolvedExerciseSnapshot(
programSnapshotId: program['id'] as String? ?? id,
exerciseSnapshotId: id,
programNameSnapshot: programName,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
timeEnabled: exercise['timeEnabled'] == true,
@ -2024,6 +2702,7 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
),
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
);
}
}
@ -2032,6 +2711,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
final class _ResolvedExerciseSnapshot {
const _ResolvedExerciseSnapshot({
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programNameSnapshot,
required this.exerciseNameSnapshot,
required this.timeEnabled,
@ -2044,8 +2725,11 @@ final class _ResolvedExerciseSnapshot {
required this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.steps = const [],
});
final String programSnapshotId;
final String exerciseSnapshotId;
final String programNameSnapshot;
final String exerciseNameSnapshot;
final bool timeEnabled;
@ -2058,6 +2742,73 @@ final class _ResolvedExerciseSnapshot {
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final List<ExerciseStep> steps;
}
List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) {
if (value is! List) {
return const [];
}
return value.map((item) {
final json = Map<String, dynamic>.from(item as Map);
return ExerciseStep(
id: json['id'] as String,
position: json['position'] as int,
name: json['name'] as String,
type: switch (json['type']) {
'time' => ExerciseStepType.time,
'reps' => ExerciseStepType.reps,
_ => throw const DomainException('Unknown exercise step type.'),
},
defaultTargetValue: json['defaultTargetValue'] as int,
hasScore: json['hasScore'] == true,
scoreInputMode: _scoreInputModeFromSnapshot(json['scoreInputMode']),
scoreLabel: json['scoreLabel'] as String?,
scoreUnit: json['scoreUnit'] as String?,
defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?,
);
}).toList(growable: false);
}
List<WorkoutHistoryStepResult> _historyStepResultsFromActiveResults({
required String historyId,
required List<ActiveExerciseStepResult> results,
required DateTime now,
required IdGenerator ids,
required String originDeviceId,
}) {
return results.map((result) {
return WorkoutHistoryStepResult(
metadata: _newMetadata(ids, originDeviceId, now),
workoutHistoryId: historyId,
programSnapshotId: result.programSnapshotId,
exerciseSnapshotId: result.exerciseSnapshotId,
programIndex: result.programIndex,
exerciseIndex: result.exerciseIndex,
setIndex: result.setIndex,
passageIndex: result.passageIndex,
stepIndex: result.stepIndex,
stepSnapshotId: result.stepSnapshotId,
stepNameSnapshot: result.stepNameSnapshot,
stepTypeSnapshot: result.stepTypeSnapshot,
targetValueSnapshot: result.targetValueSnapshot,
hasScoreSnapshot: result.hasScoreSnapshot,
scoreInputModeSnapshot: result.scoreInputModeSnapshot,
scoreLabelSnapshot: result.scoreLabelSnapshot,
scoreUnitSnapshot: result.scoreUnitSnapshot,
targetScoreSnapshot: result.targetScoreSnapshot,
targetScoreTimeMsSnapshot: result.targetScoreTimeMsSnapshot,
status: result.status,
startedAt: result.startedAt,
completedAt: result.completedAt,
actualTimeMs: result.actualTimeMs,
actualReps: result.actualReps,
actualScore: result.actualScore,
actualScoreTimeMs: result.actualScoreTimeMs,
note: result.note,
);
}).toList();
}
EntityMetadata _newMetadata(