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(

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');
}
}

View File

@ -7,6 +7,8 @@ part 'app_database.g.dart';
@DriftDatabase(
tables: [
ActiveExerciseStepProgressStates,
ActiveExerciseStepResults,
ActiveRestStates,
ActiveScoreStopwatchStates,
ActiveSetResults,
@ -20,6 +22,7 @@ part 'app_database.g.dart';
Programs,
WorkoutHistories,
WorkoutHistorySetResults,
WorkoutHistoryStepResults,
WorkoutTemplateExerciseOverrides,
WorkoutTemplatePrograms,
WorkoutTemplates,
@ -38,7 +41,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 8;
int get schemaVersion => 9;
@override
MigrationStrategy get migration {
@ -77,6 +80,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 8) {
await _migrateToSchema8(migrator);
}
if (from < 9) {
await _migrateToSchema9(migrator);
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -133,6 +139,15 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id '
'ON active_rest_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS '
'idx_active_exercise_step_progress_states_session_id '
'ON active_exercise_step_progress_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_exercise_step_results_session_id '
'ON active_exercise_step_results (active_workout_session_id)',
);
await customStatement(
'CREATE UNIQUE INDEX IF NOT EXISTS '
'idx_active_workout_sessions_single_open '
@ -148,6 +163,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
'ON workout_history_set_results (workout_history_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id '
'ON workout_history_step_results (workout_history_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_change_log_entity '
'ON change_log (entity_type, entity_id)',
@ -164,6 +183,8 @@ final class AppDatabase extends _$AppDatabase {
}
const _syncableTableNames = [
'active_exercise_step_progress_states',
'active_exercise_step_results',
'active_rest_states',
'active_score_stopwatch_states',
'active_set_results',
@ -176,6 +197,7 @@ const _syncableTableNames = [
'programs',
'workout_history',
'workout_history_set_results',
'workout_history_step_results',
'workout_template_exercise_overrides',
'workout_template_programs',
'workout_templates',
@ -287,4 +309,10 @@ extension on AppDatabase {
'exercise_steps_snapshot_json TEXT',
);
}
Future<void> _migrateToSchema9(Migrator migrator) async {
await migrator.createTable(activeExerciseStepProgressStates);
await migrator.createTable(activeExerciseStepResults);
await migrator.createTable(workoutHistoryStepResults);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -532,6 +532,38 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> saveExerciseStepProgressState(
domain.ActiveExerciseStepProgressState state,
) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_exercise_step_progress_states',
entityType: 'ActiveExerciseStepProgressState',
metadata: state.metadata,
write: () => database
.into(database.activeExerciseStepProgressStates)
.insertOnConflictUpdate(
_activeExerciseStepProgressStateCompanion(state),
),
);
}
@override
Future<void> saveExerciseStepResult(
domain.ActiveExerciseStepResult result,
) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_exercise_step_results',
entityType: 'ActiveExerciseStepResult',
metadata: result.metadata,
write: () => database
.into(database.activeExerciseStepResults)
.insertOnConflictUpdate(_activeExerciseStepResultCompanion(result)),
);
}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
@ -588,6 +620,28 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
}
@override
Future<domain.ActiveExerciseStepProgressState?>
findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final row =
await (database.select(database.activeExerciseStepProgressStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
return row == null ? null : _activeExerciseStepProgressStateFromRow(row);
}
@override
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
final rows =
@ -602,6 +656,47 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return rows.map(_activeSetResultFromRow).toList();
}
@override
Future<List<domain.ActiveExerciseStepProgressState>>
listExerciseStepProgressStates(String sessionId) async {
final rows =
await (database.select(database.activeExerciseStepProgressStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
return rows.map(_activeExerciseStepProgressStateFromRow).toList();
}
@override
Future<List<domain.ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
final rows =
await (database.select(database.activeExerciseStepResults)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
(table) => OrderingTerm.asc(table.passageIndex),
(table) => OrderingTerm.asc(table.stepIndex),
]))
.get();
return rows.map(_activeExerciseStepResultFromRow).toList();
}
@override
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
final rows =
@ -661,9 +756,25 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
final stepResults =
await (database.select(database.workoutHistoryStepResults)
..where(
(table) =>
table.workoutHistoryId.equals(id) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
(table) => OrderingTerm.asc(table.passageIndex),
(table) => OrderingTerm.asc(table.stepIndex),
]))
.get();
return _workoutHistoryFromRow(
row,
results.map(_workoutHistorySetResultFromRow).toList(),
stepResults.map(_workoutHistoryStepResultFromRow).toList(),
);
}
@ -699,6 +810,9 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
for (final result in history.results) {
await saveSetResult(result);
}
for (final result in history.stepResults) {
await saveStepResult(result);
}
});
}
@ -715,6 +829,19 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
);
}
@override
Future<void> saveStepResult(domain.WorkoutHistoryStepResult result) async {
await _upsertWithChangeLog(
database: database,
tableName: 'workout_history_step_results',
entityType: 'WorkoutHistoryStepResult',
metadata: result.metadata,
write: () => database
.into(database.workoutHistoryStepResults)
.insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result)),
);
}
@override
Future<void> delete(String id, DateTime deletedAt) async {
await database.transaction(() async {
@ -753,6 +880,18 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
resultRows,
deletedAt,
);
final stepResultRows =
await (database.select(database.workoutHistoryStepResults)..where(
(table) =>
table.workoutHistoryId.equals(id) &
table.deletedAt.isNull(),
))
.get();
await _softDeleteWorkoutHistoryStepResultRows(
database,
stepResultRows,
deletedAt,
);
});
}
}
@ -1149,6 +1288,35 @@ Future<void> _softDeleteWorkoutHistorySetResultRows(
}
}
Future<void> _softDeleteWorkoutHistoryStepResultRows(
db.AppDatabase database,
List<db.WorkoutHistoryStepResult> rows,
DateTime deletedAt,
) async {
for (final row in rows) {
final revision = row.localRevision + 1;
await (database.update(
database.workoutHistoryStepResults,
)..where((table) => table.id.equals(row.id))).write(
db.WorkoutHistoryStepResultsCompanion(
deletedAt: Value(deletedAt.toUtc()),
updatedAt: Value(deletedAt.toUtc()),
localRevision: Value(revision),
syncState: const Value('deleted'),
),
);
await _writeChangeLog(
database: database,
entityType: 'WorkoutHistoryStepResult',
entityId: row.id,
operation: 'softDelete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
}
DateTime _utc(DateTime value) => value.toUtc();
DateTime? _utcOrNull(DateTime? value) => value?.toUtc();
@ -1732,6 +1900,142 @@ domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
);
}
db.ActiveExerciseStepProgressStatesCompanion
_activeExerciseStepProgressStateCompanion(
domain.ActiveExerciseStepProgressState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveExerciseStepProgressStatesCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
programIndex: Value(state.programIndex),
exerciseIndex: Value(state.exerciseIndex),
setIndex: Value(state.setIndex),
currentPassageIndex: Value(state.currentPassageIndex),
currentStepIndex: Value(state.currentStepIndex),
currentStepSnapshotId: Value(state.currentStepSnapshotId),
status: Value(_exerciseStepProgressStatusToDb(state.status)),
startedAt: Value<DateTime?>(_utcOrNull(state.startedAt)),
accumulatedMs: Value(state.accumulatedMs),
lastTransitionAt: Value(state.lastTransitionAt.toUtc()),
);
}
domain.ActiveExerciseStepProgressState
_activeExerciseStepProgressStateFromRow(
db.ActiveExerciseStepProgressState row,
) {
return domain.ActiveExerciseStepProgressState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
currentPassageIndex: row.currentPassageIndex,
currentStepIndex: row.currentStepIndex,
currentStepSnapshotId: row.currentStepSnapshotId,
status: _exerciseStepProgressStatusFromDb(row.status),
startedAt: _utcOrNull(row.startedAt),
accumulatedMs: row.accumulatedMs,
lastTransitionAt: _utc(row.lastTransitionAt),
);
}
db.ActiveExerciseStepResultsCompanion _activeExerciseStepResultCompanion(
domain.ActiveExerciseStepResult result,
) {
final values = _metadataValues(result.metadata);
return db.ActiveExerciseStepResultsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
activeWorkoutSessionId: Value(result.activeWorkoutSessionId),
programSnapshotId: Value(result.programSnapshotId),
exerciseSnapshotId: Value(result.exerciseSnapshotId),
programIndex: Value(result.programIndex),
exerciseIndex: Value(result.exerciseIndex),
setIndex: Value(result.setIndex),
passageIndex: Value(result.passageIndex),
stepIndex: Value(result.stepIndex),
stepSnapshotId: Value(result.stepSnapshotId),
stepNameSnapshot: Value(result.stepNameSnapshot),
stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)),
targetValueSnapshot: Value(result.targetValueSnapshot),
hasScoreSnapshot: Value(result.hasScoreSnapshot),
scoreInputModeSnapshot: Value<String?>(
result.scoreInputModeSnapshot == null
? null
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
),
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
status: Value(_setResultStatusToDb(result.status)),
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
actualTimeMs: Value<int?>(result.actualTimeMs),
actualReps: Value<int?>(result.actualReps),
actualScore: Value<double?>(result.actualScore),
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
note: Value<String?>(result.note),
);
}
domain.ActiveExerciseStepResult _activeExerciseStepResultFromRow(
db.ActiveExerciseStepResult row,
) {
return domain.ActiveExerciseStepResult(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programSnapshotId: row.programSnapshotId,
exerciseSnapshotId: row.exerciseSnapshotId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
passageIndex: row.passageIndex,
stepIndex: row.stepIndex,
stepSnapshotId: row.stepSnapshotId,
stepNameSnapshot: row.stepNameSnapshot,
stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot),
targetValueSnapshot: row.targetValueSnapshot,
hasScoreSnapshot: row.hasScoreSnapshot,
scoreInputModeSnapshot: row.scoreInputModeSnapshot == null
? null
: _scoreInputModeFromDb(row.scoreInputModeSnapshot!),
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
targetScoreSnapshot: row.targetScoreSnapshot,
targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot,
status: _setResultStatusFromDb(row.status),
startedAt: _utcOrNull(row.startedAt),
completedAt: _utcOrNull(row.completedAt),
actualTimeMs: row.actualTimeMs,
actualReps: row.actualReps,
actualScore: row.actualScore,
actualScoreTimeMs: row.actualScoreTimeMs,
note: row.note,
);
}
db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
domain.WorkoutHistory history,
) {
@ -1805,9 +2109,59 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
);
}
db.WorkoutHistoryStepResultsCompanion _workoutHistoryStepResultCompanion(
domain.WorkoutHistoryStepResult result,
) {
final values = _metadataValues(result.metadata);
return db.WorkoutHistoryStepResultsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
workoutHistoryId: Value(result.workoutHistoryId),
programSnapshotId: Value(result.programSnapshotId),
exerciseSnapshotId: Value(result.exerciseSnapshotId),
programIndex: Value(result.programIndex),
exerciseIndex: Value(result.exerciseIndex),
setIndex: Value(result.setIndex),
passageIndex: Value(result.passageIndex),
stepIndex: Value(result.stepIndex),
stepSnapshotId: Value(result.stepSnapshotId),
stepNameSnapshot: Value(result.stepNameSnapshot),
stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)),
targetValueSnapshot: Value(result.targetValueSnapshot),
hasScoreSnapshot: Value(result.hasScoreSnapshot),
scoreInputModeSnapshot: Value<String?>(
result.scoreInputModeSnapshot == null
? null
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
),
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
status: Value(_setResultStatusToDb(result.status)),
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
actualTimeMs: Value<int?>(result.actualTimeMs),
actualReps: Value<int?>(result.actualReps),
actualScore: Value<double?>(result.actualScore),
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
note: Value<String?>(result.note),
);
}
domain.WorkoutHistory _workoutHistoryFromRow(
db.WorkoutHistory row,
List<domain.WorkoutHistorySetResult> results,
List<domain.WorkoutHistoryStepResult> stepResults,
) {
return domain.WorkoutHistory(
metadata: _metadataFromRow(row),
@ -1820,6 +2174,7 @@ domain.WorkoutHistory _workoutHistoryFromRow(
completed: row.completed,
historySnapshotJson: row.historySnapshotJson,
results: results,
stepResults: stepResults,
);
}
@ -1856,6 +2211,42 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow(
);
}
domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow(
db.WorkoutHistoryStepResult row,
) {
return domain.WorkoutHistoryStepResult(
metadata: _metadataFromRow(row),
workoutHistoryId: row.workoutHistoryId,
programSnapshotId: row.programSnapshotId,
exerciseSnapshotId: row.exerciseSnapshotId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
passageIndex: row.passageIndex,
stepIndex: row.stepIndex,
stepSnapshotId: row.stepSnapshotId,
stepNameSnapshot: row.stepNameSnapshot,
stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot),
targetValueSnapshot: row.targetValueSnapshot,
hasScoreSnapshot: row.hasScoreSnapshot,
scoreInputModeSnapshot: row.scoreInputModeSnapshot == null
? null
: _scoreInputModeFromDb(row.scoreInputModeSnapshot!),
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
targetScoreSnapshot: row.targetScoreSnapshot,
targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot,
status: _setResultStatusFromDb(row.status),
startedAt: _utcOrNull(row.startedAt),
completedAt: _utcOrNull(row.completedAt),
actualTimeMs: row.actualTimeMs,
actualReps: row.actualReps,
actualScore: row.actualScore,
actualScoreTimeMs: row.actualScoreTimeMs,
note: row.note,
);
}
String _syncStateToDb(domain.SyncState state) => switch (state) {
domain.SyncState.localOnly => 'localOnly',
domain.SyncState.dirty => 'dirty',
@ -1939,6 +2330,33 @@ domain.ActiveScoreStopwatchStatus _scoreStopwatchStatusFromDb(String value) =>
),
};
String _exerciseStepProgressStatusToDb(
domain.ActiveExerciseStepProgressStatus status,
) => switch (status) {
domain.ActiveExerciseStepProgressStatus.notStarted => 'notStarted',
domain.ActiveExerciseStepProgressStatus.waitingManual => 'waitingManual',
domain.ActiveExerciseStepProgressStatus.runningTimer => 'runningTimer',
domain.ActiveExerciseStepProgressStatus.pausedTimer => 'pausedTimer',
domain.ActiveExerciseStepProgressStatus.stoppedTimer => 'stoppedTimer',
domain.ActiveExerciseStepProgressStatus.sequenceComplete =>
'sequenceComplete',
};
domain.ActiveExerciseStepProgressStatus _exerciseStepProgressStatusFromDb(
String value,
) => switch (value) {
'notStarted' => domain.ActiveExerciseStepProgressStatus.notStarted,
'waitingManual' => domain.ActiveExerciseStepProgressStatus.waitingManual,
'runningTimer' => domain.ActiveExerciseStepProgressStatus.runningTimer,
'pausedTimer' => domain.ActiveExerciseStepProgressStatus.pausedTimer,
'stoppedTimer' => domain.ActiveExerciseStepProgressStatus.stoppedTimer,
'sequenceComplete' =>
domain.ActiveExerciseStepProgressStatus.sequenceComplete,
_ => throw domain.DomainException(
'Unknown active exercise step progress status: $value',
),
};
String? _encodeImageMediaIdsSnapshot(List<String> imageMediaIds) {
return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds);
}

View File

@ -419,6 +419,108 @@ class ActiveRestStates extends SyncableTable {
];
}
class ActiveExerciseStepProgressStates extends SyncableTable {
@override
String get tableName => 'active_exercise_step_progress_states';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
IntColumn get currentPassageIndex => integer()();
IntColumn get currentStepIndex => integer()();
TextColumn get currentStepSnapshotId => text().withLength(min: 1)();
TextColumn get status => text()();
DateTimeColumn get startedAt => dateTime().nullable()();
IntColumn get accumulatedMs => integer()();
DateTimeColumn get lastTransitionAt => dateTime()();
@override
List<String> get customConstraints => [
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
'set_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (current_passage_index >= 0)',
'CHECK (current_step_index >= 0)',
"CHECK (status IN ('notStarted', 'waitingManual', 'runningTimer', "
"'pausedTimer', 'stoppedTimer', 'sequenceComplete'))",
'CHECK (status != \'runningTimer\' OR started_at IS NOT NULL)',
'CHECK (accumulated_ms >= 0)',
];
}
class ActiveExerciseStepResults extends SyncableTable {
@override
String get tableName => 'active_exercise_step_results';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
TextColumn get programSnapshotId => text().withLength(min: 1)();
TextColumn get exerciseSnapshotId => text().withLength(min: 1)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
IntColumn get passageIndex => integer()();
IntColumn get stepIndex => integer()();
TextColumn get stepSnapshotId => text().withLength(min: 1)();
TextColumn get stepNameSnapshot => text().withLength(min: 1)();
TextColumn get stepTypeSnapshot => text()();
IntColumn get targetValueSnapshot => integer()();
BoolColumn get hasScoreSnapshot => boolean()();
TextColumn get scoreInputModeSnapshot => text().nullable()();
TextColumn get scoreLabelSnapshot => text().nullable()();
TextColumn get scoreUnitSnapshot => text().nullable()();
RealColumn get targetScoreSnapshot => real().nullable()();
IntColumn get targetScoreTimeMsSnapshot => integer().nullable()();
TextColumn get status => text()();
DateTimeColumn get startedAt => dateTime().nullable()();
DateTimeColumn get completedAt => dateTime().nullable()();
IntColumn get actualTimeMs => integer().nullable()();
IntColumn get actualReps => integer().nullable()();
RealColumn get actualScore => real().nullable()();
IntColumn get actualScoreTimeMs => integer().nullable()();
TextColumn get note => text().nullable()();
@override
List<String> get customConstraints => [
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
'set_index, passage_index, step_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (passage_index >= 0)',
'CHECK (step_index >= 0)',
"CHECK (step_type_snapshot IN ('time', 'reps'))",
'CHECK (target_value_snapshot > 0)',
"CHECK (score_input_mode_snapshot IS NULL OR "
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
"CHECK (status IN ('completed', 'skipped'))",
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',
'CHECK (actual_reps IS NULL OR actual_reps >= 0)',
'CHECK (actual_score IS NULL OR actual_score >= 0)',
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
'CHECK (actual_time_ms IS NULL OR step_type_snapshot = \'time\')',
'CHECK (actual_reps IS NULL OR step_type_snapshot = \'reps\')',
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
'AND actual_reps IS NULL AND actual_score IS NULL '
'AND actual_score_time_ms IS NULL))',
'CHECK (target_score_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (target_score_time_ms_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (actual_score_time_ms IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
'CHECK (target_score_snapshot IS NULL OR '
'target_score_time_ms_snapshot IS NULL)',
];
}
class WorkoutHistories extends SyncableTable {
@override
String get tableName => 'workout_history';
@ -511,6 +613,74 @@ class WorkoutHistorySetResults extends SyncableTable {
];
}
class WorkoutHistoryStepResults extends SyncableTable {
@override
String get tableName => 'workout_history_step_results';
TextColumn get workoutHistoryId => text().references(WorkoutHistories, #id)();
TextColumn get programSnapshotId => text().withLength(min: 1)();
TextColumn get exerciseSnapshotId => text().withLength(min: 1)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
IntColumn get passageIndex => integer()();
IntColumn get stepIndex => integer()();
TextColumn get stepSnapshotId => text().withLength(min: 1)();
TextColumn get stepNameSnapshot => text().withLength(min: 1)();
TextColumn get stepTypeSnapshot => text()();
IntColumn get targetValueSnapshot => integer()();
BoolColumn get hasScoreSnapshot => boolean()();
TextColumn get scoreInputModeSnapshot => text().nullable()();
TextColumn get scoreLabelSnapshot => text().nullable()();
TextColumn get scoreUnitSnapshot => text().nullable()();
RealColumn get targetScoreSnapshot => real().nullable()();
IntColumn get targetScoreTimeMsSnapshot => integer().nullable()();
TextColumn get status => text()();
DateTimeColumn get startedAt => dateTime().nullable()();
DateTimeColumn get completedAt => dateTime().nullable()();
IntColumn get actualTimeMs => integer().nullable()();
IntColumn get actualReps => integer().nullable()();
RealColumn get actualScore => real().nullable()();
IntColumn get actualScoreTimeMs => integer().nullable()();
TextColumn get note => text().nullable()();
@override
List<String> get customConstraints => [
'UNIQUE (workout_history_id, program_index, exercise_index, set_index, '
'passage_index, step_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (passage_index >= 0)',
'CHECK (step_index >= 0)',
"CHECK (step_type_snapshot IN ('time', 'reps'))",
'CHECK (target_value_snapshot > 0)',
"CHECK (score_input_mode_snapshot IS NULL OR "
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
"CHECK (status IN ('completed', 'skipped'))",
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',
'CHECK (actual_reps IS NULL OR actual_reps >= 0)',
'CHECK (actual_score IS NULL OR actual_score >= 0)',
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
'CHECK (actual_time_ms IS NULL OR step_type_snapshot = \'time\')',
'CHECK (actual_reps IS NULL OR step_type_snapshot = \'reps\')',
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
'AND actual_reps IS NULL AND actual_score IS NULL '
'AND actual_score_time_ms IS NULL))',
'CHECK (target_score_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (target_score_time_ms_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (actual_score_time_ms IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
'CHECK (target_score_snapshot IS NULL OR '
'target_score_time_ms_snapshot IS NULL)',
];
}
class ChangeLogEntries extends Table {
@override
String get tableName => 'change_log';

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
@ -400,7 +402,11 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
var _saving = false;
var _importingImage = false;
var _importingVideo = false;
var _stepsEnabled = false;
var _nextStepDraftId = 0;
final _stepDrafts = <_ExerciseStepDraft>[];
String? _measureError;
String? _stepError;
String? _warning;
bool get _isEditing => widget.exercise != null;
@ -434,6 +440,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
_stepDrafts.add(_draftFromStep(step));
}
}
@override
@ -446,6 +456,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_defaultRepsController.dispose();
_defaultScoreController.dispose();
_defaultScoreTimeController.dispose();
for (final draft in _stepDrafts) {
draft.dispose();
}
super.dispose();
}
@ -666,6 +679,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
),
],
],
const SizedBox(height: 24),
_buildExerciseStepsSection(context),
if (_warning != null) ...[
const SizedBox(height: 16),
MaterialBanner(
@ -696,6 +711,305 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
);
}
Widget _buildExerciseStepsSection(BuildContext context) {
final reachedLimit = _stepDrafts.length >= 8;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Séquence détapes',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Rythmer cet exercice avec des étapes'),
subtitle: Text(
_stepsEnabled
? 'Chaque passage suit ces étapes dans lordre. Si la série '
'suit des répétitions, une répétition correspond à un '
'passage complet.'
: 'Ajoute des étapes si lexercice doit suivre un ordre précis '
'pendant chaque série.',
),
value: _stepsEnabled,
onChanged: (value) {
setState(() {
_stepsEnabled = value;
_stepError = null;
});
},
),
if (_stepsEnabled) ...[
const SizedBox(height: 8),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _stepDrafts.length,
onReorder: _reorderStep,
itemBuilder: (context, index) {
return _buildStepCard(context, index, _stepDrafts[index]);
},
),
if (_stepError != null) ...[
const SizedBox(height: 8),
Text(
_stepError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 8),
if (reachedLimit)
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.info_outline),
title: Text('Limite atteinte'),
subtitle: Text('Un exercice peut contenir jusquà 8 étapes.'),
)
else
OutlinedButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add),
label: const Text('Ajouter une étape'),
),
],
],
);
}
Widget _buildStepCard(
BuildContext context,
int index,
_ExerciseStepDraft draft,
) {
final targetLabel = draft.type == ExerciseStepType.time
? 'Durée par défaut (s)'
: 'Répétitions par défaut';
final targetMessage = draft.type == ExerciseStepType.time
? 'Saisis une durée supérieure à 0.'
: 'Saisis un nombre de répétitions supérieur à 0.';
final summaryUnit = draft.type == ExerciseStepType.time ? 's' : '';
return CourtBlazerAccentPanel(
key: ValueKey(draft.id),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
child: Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.drag_handle),
),
),
Expanded(
child: Text(
'${index + 1}. ${draft.displayName}',
style: Theme.of(context).textTheme.titleSmall,
),
),
PopupMenuButton<_StepAction>(
tooltip: 'Actions de létape ${index + 1}',
onSelected: (action) => _handleStepAction(index, action),
itemBuilder: (context) => [
PopupMenuItem(
value: _StepAction.moveUp,
enabled: index > 0,
child: const Text('Monter'),
),
PopupMenuItem(
value: _StepAction.moveDown,
enabled: index < _stepDrafts.length - 1,
child: const Text('Descendre'),
),
const PopupMenuItem(
value: _StepAction.duplicate,
child: Text('Dupliquer'),
),
const PopupMenuItem(
value: _StepAction.delete,
child: Text('Supprimer'),
),
],
),
],
),
const SizedBox(height: 8),
Text(
draft.type == ExerciseStepType.time
? 'Temps · ${draft.targetController.text.trim()} $summaryUnit'
: 'Répétitions · ${draft.targetController.text.trim()}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextFormField(
controller: draft.nameController,
decoration: const InputDecoration(labelText: 'Nom de létape'),
validator: (value) {
if (!_stepsEnabled) return null;
return value == null || value.trim().isEmpty
? 'Le nom de létape est obligatoire.'
: null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Text('Type détape', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: ExerciseStepType.time,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: ExerciseStepType.reps,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
TextFormField(
controller: draft.targetController,
decoration: InputDecoration(labelText: targetLabel),
keyboardType: TextInputType.number,
validator: (value) =>
_stepsEnabled ? _positiveIntValidator(value, targetMessage) : null,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Score détape'),
subtitle: const Text('Ajouter un score pour cette étape'),
value: draft.hasScore,
onChanged: (value) => setState(() => draft.hasScore = value),
),
if (draft.hasScore) ...[
Text('Mode de score', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(labelText: 'Score à saisir'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
] else ...[
if (draft.type == ExerciseStepType.time) ...[
const SizedBox(height: 8),
const Text(
'Cette étape utilise déjà un compte à rebours. Le score '
'chrono ajoute un second temps mesuré ; garde-le seulement '
'si tu veux enregistrer une performance distincte.',
),
],
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.stopwatch ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _positiveDoubleValidator(
value,
'Saisis un objectif supérieur à 0.',
);
},
),
],
],
],
),
),
);
}
void _setMeasure(VoidCallback update) {
setState(() {
update();
@ -704,6 +1018,141 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
});
}
void _addStep() {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.add(
_ExerciseStepDraft(
id: 'step-draft-${_nextStepDraftId++}',
name: 'Nouvelle étape',
defaultTargetValue: '10',
),
);
});
}
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final draft = _stepDrafts.removeAt(oldIndex);
_stepDrafts.insert(newIndex, draft);
});
}
void _handleStepAction(int index, _StepAction action) {
switch (action) {
case _StepAction.moveUp:
if (index <= 0) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index - 1, draft);
});
break;
case _StepAction.moveDown:
if (index >= _stepDrafts.length - 1) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index + 1, draft);
});
break;
case _StepAction.duplicate:
_duplicateStep(index);
break;
case _StepAction.delete:
unawaited(_confirmRemoveStep(index));
break;
}
}
void _duplicateStep(int index) {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.insert(index + 1, _stepDrafts[index].duplicate(
'step-draft-${_nextStepDraftId++}',
));
});
}
Future<void> _confirmRemoveStep(int index) async {
final draft = _stepDrafts[index];
var confirmed = true;
if (draft.hasFilledContent) {
confirmed =
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer cette étape ?'),
content: const Text('Cette étape sera retirée de la séquence.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Supprimer'),
),
],
),
) ??
false;
}
if (!confirmed || !mounted) return;
setState(() {
final removed = _stepDrafts.removeAt(index);
removed.dispose();
_stepError = null;
});
}
_ExerciseStepDraft _draftFromStep(ExerciseStep step) {
return _ExerciseStepDraft(
id: step.id,
name: step.name,
type: step.type,
defaultTargetValue: step.defaultTargetValue.toString(),
hasScore: step.hasScore,
scoreInputMode: step.scoreInputMode,
scoreLabel: step.scoreLabel,
scoreUnit: step.scoreUnit,
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
? _optionalDoubleText(
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
)
: _optionalDoubleText(step.defaultTargetScore),
);
}
List<ExerciseStep> _buildSteps() {
if (!_stepsEnabled) return const [];
return [
for (var index = 0; index < _stepDrafts.length; index++)
_stepDrafts[index].toExerciseStep(
position: index,
defaultTargetScoreTimeMs: _stepDrafts[index].scoreInputMode ==
ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(
_stepDrafts[index].scoreTargetController.text,
)
: null,
),
];
}
Future<void> _importImage() async {
if (_imageMediaIds.length >= 5) {
_showSnackBar('Maximum 5 images par exercice.');
@ -784,12 +1233,16 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
Future<void> _save() async {
final hasMeasure = _hasTime || _hasReps || _hasScore;
final hasValidSteps = !_stepsEnabled || _stepDrafts.isNotEmpty;
setState(() {
_measureError = hasMeasure
? null
: 'Active au moins une mesure pour enregistrer.';
_stepError = hasValidSteps
? null
: 'Ajoute au moins une étape ou désactive la séquence.';
});
if (!_formKey.currentState!.validate() || !hasMeasure) {
if (!_formKey.currentState!.validate() || !hasMeasure || !hasValidSteps) {
return;
}
@ -832,6 +1285,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
try {
if (exercise == null) {
await widget.exerciseUseCases.create(
@ -850,6 +1304,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
);
} else {
await widget.exerciseUseCases.update(
@ -869,6 +1324,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
);
}
if (mounted) {
@ -961,6 +1417,104 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
}
enum _StepAction { moveUp, moveDown, duplicate, delete }
final class _ExerciseStepDraft {
_ExerciseStepDraft({
required this.id,
required String name,
this.type = ExerciseStepType.time,
required String defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
String? scoreTarget,
}) : nameController = TextEditingController(text: name),
targetController = TextEditingController(text: defaultTargetValue),
scoreLabelController = TextEditingController(text: scoreLabel),
scoreUnitController = TextEditingController(text: scoreUnit),
scoreTargetController = TextEditingController(text: scoreTarget);
final String id;
ExerciseStepType type;
bool hasScore;
ScoreInputMode scoreInputMode;
final TextEditingController nameController;
final TextEditingController targetController;
final TextEditingController scoreLabelController;
final TextEditingController scoreUnitController;
final TextEditingController scoreTargetController;
String get displayName {
final text = nameController.text.trim();
return text.isEmpty ? 'Étape sans nom' : text;
}
bool get hasFilledContent {
return nameController.text.trim().isNotEmpty ||
targetController.text.trim().isNotEmpty ||
hasScore ||
scoreLabelController.text.trim().isNotEmpty ||
scoreUnitController.text.trim().isNotEmpty ||
scoreTargetController.text.trim().isNotEmpty;
}
_ExerciseStepDraft duplicate(String id) {
return _ExerciseStepDraft(
id: id,
name: nameController.text,
type: type,
defaultTargetValue: targetController.text,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabelController.text,
scoreUnit: scoreUnitController.text,
scoreTarget: scoreTargetController.text,
);
}
ExerciseStep toExerciseStep({
required int position,
required int? defaultTargetScoreTimeMs,
}) {
final scoreTargetText = scoreTargetController.text.trim();
return ExerciseStep(
id: id,
position: position,
name: nameController.text.trim(),
type: type,
defaultTargetValue: int.parse(targetController.text.trim()),
hasScore: hasScore,
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreLabelController.text.trim()
: null,
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreUnitController.text.trim()
: null,
defaultTargetScore:
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
scoreTargetText.isNotEmpty
? double.parse(scoreTargetText)
: null,
defaultTargetScoreTimeMs:
hasScore && scoreInputMode == ScoreInputMode.stopwatch
? defaultTargetScoreTimeMs
: null,
);
}
void dispose() {
nameController.dispose();
targetController.dispose();
scoreLabelController.dispose();
scoreUnitController.dispose();
scoreTargetController.dispose();
}
}
final class MeasureBadge extends StatelessWidget {
const MeasureBadge({
required this.measure,