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:
@ -81,6 +81,10 @@ abstract interface class ActiveSessionRepository {
|
|||||||
Future<void> saveSetResult(ActiveSetResult result);
|
Future<void> saveSetResult(ActiveSetResult result);
|
||||||
Future<void> saveRestState(ActiveRestState restState);
|
Future<void> saveRestState(ActiveRestState restState);
|
||||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
|
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
|
||||||
|
Future<void> saveExerciseStepProgressState(
|
||||||
|
ActiveExerciseStepProgressState state,
|
||||||
|
);
|
||||||
|
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result);
|
||||||
Future<void> deleteScoreStopwatchState({
|
Future<void> deleteScoreStopwatchState({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
required int programIndex,
|
required int programIndex,
|
||||||
@ -95,7 +99,19 @@ abstract interface class ActiveSessionRepository {
|
|||||||
required int exerciseIndex,
|
required int exerciseIndex,
|
||||||
required int setIndex,
|
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<ActiveSetResult>> listSetResults(String sessionId);
|
||||||
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||||||
|
String sessionId,
|
||||||
|
);
|
||||||
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||||||
|
String sessionId,
|
||||||
|
);
|
||||||
Future<List<ActiveRestState>> listRestStates(String sessionId);
|
Future<List<ActiveRestState>> listRestStates(String sessionId);
|
||||||
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
||||||
String sessionId,
|
String sessionId,
|
||||||
@ -107,6 +123,7 @@ abstract interface class WorkoutHistoryRepository {
|
|||||||
Future<List<WorkoutHistory>> listActive();
|
Future<List<WorkoutHistory>> listActive();
|
||||||
Future<void> save(WorkoutHistory history);
|
Future<void> save(WorkoutHistory history);
|
||||||
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
||||||
|
Future<void> saveStepResult(WorkoutHistoryStepResult result);
|
||||||
Future<void> delete(String id, DateTime deletedAt);
|
Future<void> delete(String id, DateTime deletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,6 +68,7 @@ final class ExerciseUseCases {
|
|||||||
defaultTargetReps: defaultTargetReps,
|
defaultTargetReps: defaultTargetReps,
|
||||||
defaultTargetScore: defaultTargetScore,
|
defaultTargetScore: defaultTargetScore,
|
||||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||||
|
steps: steps,
|
||||||
);
|
);
|
||||||
await repository.save(exercise);
|
await repository.save(exercise);
|
||||||
return exercise;
|
return exercise;
|
||||||
@ -962,6 +963,23 @@ final class ActiveWorkoutSessionUseCases {
|
|||||||
);
|
);
|
||||||
await sessionRepository.saveScoreStopwatchState(stopped);
|
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);
|
final paused = session.pause(now);
|
||||||
await sessionRepository.save(paused);
|
await sessionRepository.save(paused);
|
||||||
return 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 {
|
final class CloseWorkoutSessionUseCase {
|
||||||
const CloseWorkoutSessionUseCase({
|
const CloseWorkoutSessionUseCase({
|
||||||
required this.sessionRepository,
|
required this.sessionRepository,
|
||||||
@ -1467,6 +2034,9 @@ final class CloseWorkoutSessionUseCase {
|
|||||||
}
|
}
|
||||||
final now = clock.now();
|
final now = clock.now();
|
||||||
final results = await sessionRepository.listSetResults(sessionId);
|
final results = await sessionRepository.listSetResults(sessionId);
|
||||||
|
final stepResults = await sessionRepository.listExerciseStepResults(
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
final historyId = ids.newId();
|
final historyId = ids.newId();
|
||||||
final historyResults = _historyResultsFromActiveResults(
|
final historyResults = _historyResultsFromActiveResults(
|
||||||
historyId: historyId,
|
historyId: historyId,
|
||||||
@ -1476,6 +2046,13 @@ final class CloseWorkoutSessionUseCase {
|
|||||||
ids: ids,
|
ids: ids,
|
||||||
originDeviceId: originDeviceId,
|
originDeviceId: originDeviceId,
|
||||||
);
|
);
|
||||||
|
final historyStepResults = _historyStepResultsFromActiveResults(
|
||||||
|
historyId: historyId,
|
||||||
|
results: stepResults,
|
||||||
|
now: now,
|
||||||
|
ids: ids,
|
||||||
|
originDeviceId: originDeviceId,
|
||||||
|
);
|
||||||
final history = WorkoutHistory(
|
final history = WorkoutHistory(
|
||||||
metadata: EntityMetadata(
|
metadata: EntityMetadata(
|
||||||
id: historyId,
|
id: historyId,
|
||||||
@ -1512,8 +2089,38 @@ final class CloseWorkoutSessionUseCase {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.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,
|
results: historyResults,
|
||||||
|
stepResults: historyStepResults,
|
||||||
);
|
);
|
||||||
await historyRepository.save(history);
|
await historyRepository.save(history);
|
||||||
return history;
|
return history;
|
||||||
@ -1867,6 +2474,53 @@ _SetPositionSnapshot? _findSetSnapshot({
|
|||||||
return null;
|
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(
|
List<_SetPositionSnapshot> _listSetSnapshots(
|
||||||
String resolvedTemplateSnapshotJson,
|
String resolvedTemplateSnapshotJson,
|
||||||
) {
|
) {
|
||||||
@ -1938,6 +2592,28 @@ final class _SetPositionSnapshot {
|
|||||||
final ScoreInputMode scoreInputModeSnapshot;
|
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({
|
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
|
||||||
required String historyId,
|
required String historyId,
|
||||||
required List<ActiveSetResult> results,
|
required List<ActiveSetResult> results,
|
||||||
@ -2010,6 +2686,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
snapshots[id] = _ResolvedExerciseSnapshot(
|
snapshots[id] = _ResolvedExerciseSnapshot(
|
||||||
|
programSnapshotId: program['id'] as String? ?? id,
|
||||||
|
exerciseSnapshotId: id,
|
||||||
programNameSnapshot: programName,
|
programNameSnapshot: programName,
|
||||||
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
|
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
|
||||||
timeEnabled: exercise['timeEnabled'] == true,
|
timeEnabled: exercise['timeEnabled'] == true,
|
||||||
@ -2024,6 +2702,7 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
|||||||
),
|
),
|
||||||
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
||||||
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
||||||
|
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2032,6 +2711,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
|||||||
|
|
||||||
final class _ResolvedExerciseSnapshot {
|
final class _ResolvedExerciseSnapshot {
|
||||||
const _ResolvedExerciseSnapshot({
|
const _ResolvedExerciseSnapshot({
|
||||||
|
required this.programSnapshotId,
|
||||||
|
required this.exerciseSnapshotId,
|
||||||
required this.programNameSnapshot,
|
required this.programNameSnapshot,
|
||||||
required this.exerciseNameSnapshot,
|
required this.exerciseNameSnapshot,
|
||||||
required this.timeEnabled,
|
required this.timeEnabled,
|
||||||
@ -2044,8 +2725,11 @@ final class _ResolvedExerciseSnapshot {
|
|||||||
required this.scoreInputModeSnapshot,
|
required this.scoreInputModeSnapshot,
|
||||||
this.scoreLabelSnapshot,
|
this.scoreLabelSnapshot,
|
||||||
this.scoreUnitSnapshot,
|
this.scoreUnitSnapshot,
|
||||||
|
this.steps = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String programSnapshotId;
|
||||||
|
final String exerciseSnapshotId;
|
||||||
final String programNameSnapshot;
|
final String programNameSnapshot;
|
||||||
final String exerciseNameSnapshot;
|
final String exerciseNameSnapshot;
|
||||||
final bool timeEnabled;
|
final bool timeEnabled;
|
||||||
@ -2058,6 +2742,73 @@ final class _ResolvedExerciseSnapshot {
|
|||||||
final ScoreInputMode scoreInputModeSnapshot;
|
final ScoreInputMode scoreInputModeSnapshot;
|
||||||
final String? scoreLabelSnapshot;
|
final String? scoreLabelSnapshot;
|
||||||
final String? scoreUnitSnapshot;
|
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(
|
EntityMetadata _newMetadata(
|
||||||
|
|||||||
@ -16,6 +16,15 @@ enum SetResultStatus { completed, skipped }
|
|||||||
|
|
||||||
enum ActiveScoreStopwatchStatus { running, stopped }
|
enum ActiveScoreStopwatchStatus { running, stopped }
|
||||||
|
|
||||||
|
enum ActiveExerciseStepProgressStatus {
|
||||||
|
notStarted,
|
||||||
|
waitingManual,
|
||||||
|
runningTimer,
|
||||||
|
pausedTimer,
|
||||||
|
stoppedTimer,
|
||||||
|
sequenceComplete,
|
||||||
|
}
|
||||||
|
|
||||||
final class DomainException implements Exception {
|
final class DomainException implements Exception {
|
||||||
const DomainException(this.message);
|
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 {
|
final class WorkoutHistory {
|
||||||
const WorkoutHistory({
|
const WorkoutHistory({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
@ -928,6 +1097,7 @@ final class WorkoutHistory {
|
|||||||
required this.completed,
|
required this.completed,
|
||||||
required this.historySnapshotJson,
|
required this.historySnapshotJson,
|
||||||
this.results = const [],
|
this.results = const [],
|
||||||
|
this.stepResults = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final EntityMetadata metadata;
|
final EntityMetadata metadata;
|
||||||
@ -940,6 +1110,7 @@ final class WorkoutHistory {
|
|||||||
final bool completed;
|
final bool completed;
|
||||||
final String historySnapshotJson;
|
final String historySnapshotJson;
|
||||||
final List<WorkoutHistorySetResult> results;
|
final List<WorkoutHistorySetResult> results;
|
||||||
|
final List<WorkoutHistoryStepResult> stepResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class WorkoutHistorySetResult {
|
final class WorkoutHistorySetResult {
|
||||||
@ -1021,6 +1192,87 @@ final class WorkoutHistorySetResult {
|
|||||||
final SetResultStatus status;
|
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) {
|
String _nonBlank(String? value, String label) {
|
||||||
final trimmed = value?.trim();
|
final trimmed = value?.trim();
|
||||||
if (trimmed == null || trimmed.isEmpty) {
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ part 'app_database.g.dart';
|
|||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
tables: [
|
tables: [
|
||||||
|
ActiveExerciseStepProgressStates,
|
||||||
|
ActiveExerciseStepResults,
|
||||||
ActiveRestStates,
|
ActiveRestStates,
|
||||||
ActiveScoreStopwatchStates,
|
ActiveScoreStopwatchStates,
|
||||||
ActiveSetResults,
|
ActiveSetResults,
|
||||||
@ -20,6 +22,7 @@ part 'app_database.g.dart';
|
|||||||
Programs,
|
Programs,
|
||||||
WorkoutHistories,
|
WorkoutHistories,
|
||||||
WorkoutHistorySetResults,
|
WorkoutHistorySetResults,
|
||||||
|
WorkoutHistoryStepResults,
|
||||||
WorkoutTemplateExerciseOverrides,
|
WorkoutTemplateExerciseOverrides,
|
||||||
WorkoutTemplatePrograms,
|
WorkoutTemplatePrograms,
|
||||||
WorkoutTemplates,
|
WorkoutTemplates,
|
||||||
@ -38,7 +41,7 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 8;
|
int get schemaVersion => 9;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@ -77,6 +80,9 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 8) {
|
if (from < 8) {
|
||||||
await _migrateToSchema8(migrator);
|
await _migrateToSchema8(migrator);
|
||||||
}
|
}
|
||||||
|
if (from < 9) {
|
||||||
|
await _migrateToSchema9(migrator);
|
||||||
|
}
|
||||||
await _createIndexes();
|
await _createIndexes();
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
@ -133,6 +139,15 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id '
|
'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id '
|
||||||
'ON active_rest_states (active_workout_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(
|
await customStatement(
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
||||||
'idx_active_workout_sessions_single_open '
|
'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 '
|
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
|
||||||
'ON workout_history_set_results (workout_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(
|
await customStatement(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_change_log_entity '
|
'CREATE INDEX IF NOT EXISTS idx_change_log_entity '
|
||||||
'ON change_log (entity_type, entity_id)',
|
'ON change_log (entity_type, entity_id)',
|
||||||
@ -164,6 +183,8 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const _syncableTableNames = [
|
const _syncableTableNames = [
|
||||||
|
'active_exercise_step_progress_states',
|
||||||
|
'active_exercise_step_results',
|
||||||
'active_rest_states',
|
'active_rest_states',
|
||||||
'active_score_stopwatch_states',
|
'active_score_stopwatch_states',
|
||||||
'active_set_results',
|
'active_set_results',
|
||||||
@ -176,6 +197,7 @@ const _syncableTableNames = [
|
|||||||
'programs',
|
'programs',
|
||||||
'workout_history',
|
'workout_history',
|
||||||
'workout_history_set_results',
|
'workout_history_set_results',
|
||||||
|
'workout_history_step_results',
|
||||||
'workout_template_exercise_overrides',
|
'workout_template_exercise_overrides',
|
||||||
'workout_template_programs',
|
'workout_template_programs',
|
||||||
'workout_templates',
|
'workout_templates',
|
||||||
@ -287,4 +309,10 @@ extension on AppDatabase {
|
|||||||
'exercise_steps_snapshot_json TEXT',
|
'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
@ -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
|
@override
|
||||||
Future<void> deleteScoreStopwatchState({
|
Future<void> deleteScoreStopwatchState({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@ -588,6 +620,28 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
|
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
|
@override
|
||||||
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
|
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
|
||||||
final rows =
|
final rows =
|
||||||
@ -602,6 +656,47 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return rows.map(_activeSetResultFromRow).toList();
|
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
|
@override
|
||||||
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
|
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
|
||||||
final rows =
|
final rows =
|
||||||
@ -661,9 +756,25 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
(table) => OrderingTerm.asc(table.setIndex),
|
(table) => OrderingTerm.asc(table.setIndex),
|
||||||
]))
|
]))
|
||||||
.get();
|
.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(
|
return _workoutHistoryFromRow(
|
||||||
row,
|
row,
|
||||||
results.map(_workoutHistorySetResultFromRow).toList(),
|
results.map(_workoutHistorySetResultFromRow).toList(),
|
||||||
|
stepResults.map(_workoutHistoryStepResultFromRow).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -699,6 +810,9 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
for (final result in history.results) {
|
for (final result in history.results) {
|
||||||
await saveSetResult(result);
|
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
|
@override
|
||||||
Future<void> delete(String id, DateTime deletedAt) async {
|
Future<void> delete(String id, DateTime deletedAt) async {
|
||||||
await database.transaction(() async {
|
await database.transaction(() async {
|
||||||
@ -753,6 +880,18 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
resultRows,
|
resultRows,
|
||||||
deletedAt,
|
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 _utc(DateTime value) => value.toUtc();
|
||||||
|
|
||||||
DateTime? _utcOrNull(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(
|
db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
|
||||||
domain.WorkoutHistory history,
|
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(
|
domain.WorkoutHistory _workoutHistoryFromRow(
|
||||||
db.WorkoutHistory row,
|
db.WorkoutHistory row,
|
||||||
List<domain.WorkoutHistorySetResult> results,
|
List<domain.WorkoutHistorySetResult> results,
|
||||||
|
List<domain.WorkoutHistoryStepResult> stepResults,
|
||||||
) {
|
) {
|
||||||
return domain.WorkoutHistory(
|
return domain.WorkoutHistory(
|
||||||
metadata: _metadataFromRow(row),
|
metadata: _metadataFromRow(row),
|
||||||
@ -1820,6 +2174,7 @@ domain.WorkoutHistory _workoutHistoryFromRow(
|
|||||||
completed: row.completed,
|
completed: row.completed,
|
||||||
historySnapshotJson: row.historySnapshotJson,
|
historySnapshotJson: row.historySnapshotJson,
|
||||||
results: results,
|
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) {
|
String _syncStateToDb(domain.SyncState state) => switch (state) {
|
||||||
domain.SyncState.localOnly => 'localOnly',
|
domain.SyncState.localOnly => 'localOnly',
|
||||||
domain.SyncState.dirty => 'dirty',
|
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) {
|
String? _encodeImageMediaIdsSnapshot(List<String> imageMediaIds) {
|
||||||
return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds);
|
return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
class WorkoutHistories extends SyncableTable {
|
||||||
@override
|
@override
|
||||||
String get tableName => 'workout_history';
|
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 {
|
class ChangeLogEntries extends Table {
|
||||||
@override
|
@override
|
||||||
String get tableName => 'change_log';
|
String get tableName => 'change_log';
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
@ -400,7 +402,11 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
var _saving = false;
|
var _saving = false;
|
||||||
var _importingImage = false;
|
var _importingImage = false;
|
||||||
var _importingVideo = false;
|
var _importingVideo = false;
|
||||||
|
var _stepsEnabled = false;
|
||||||
|
var _nextStepDraftId = 0;
|
||||||
|
final _stepDrafts = <_ExerciseStepDraft>[];
|
||||||
String? _measureError;
|
String? _measureError;
|
||||||
|
String? _stepError;
|
||||||
String? _warning;
|
String? _warning;
|
||||||
|
|
||||||
bool get _isEditing => widget.exercise != null;
|
bool get _isEditing => widget.exercise != null;
|
||||||
@ -434,6 +440,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
_hasReps = exercise?.hasRepsMeasure ?? false;
|
_hasReps = exercise?.hasRepsMeasure ?? false;
|
||||||
_hasScore = exercise?.hasScoreMeasure ?? false;
|
_hasScore = exercise?.hasScoreMeasure ?? false;
|
||||||
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
|
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
|
||||||
|
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
|
||||||
|
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
|
||||||
|
_stepDrafts.add(_draftFromStep(step));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -446,6 +456,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
_defaultRepsController.dispose();
|
_defaultRepsController.dispose();
|
||||||
_defaultScoreController.dispose();
|
_defaultScoreController.dispose();
|
||||||
_defaultScoreTimeController.dispose();
|
_defaultScoreTimeController.dispose();
|
||||||
|
for (final draft in _stepDrafts) {
|
||||||
|
draft.dispose();
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -666,6 +679,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildExerciseStepsSection(context),
|
||||||
if (_warning != null) ...[
|
if (_warning != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
MaterialBanner(
|
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 l’ordre. Si la série '
|
||||||
|
'suit des répétitions, une répétition correspond à un '
|
||||||
|
'passage complet.'
|
||||||
|
: 'Ajoute des étapes si l’exercice 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
|
||||||
|
? 'L’unité 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) {
|
void _setMeasure(VoidCallback update) {
|
||||||
setState(() {
|
setState(() {
|
||||||
update();
|
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 {
|
Future<void> _importImage() async {
|
||||||
if (_imageMediaIds.length >= 5) {
|
if (_imageMediaIds.length >= 5) {
|
||||||
_showSnackBar('Maximum 5 images par exercice.');
|
_showSnackBar('Maximum 5 images par exercice.');
|
||||||
@ -784,12 +1233,16 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final hasMeasure = _hasTime || _hasReps || _hasScore;
|
final hasMeasure = _hasTime || _hasReps || _hasScore;
|
||||||
|
final hasValidSteps = !_stepsEnabled || _stepDrafts.isNotEmpty;
|
||||||
setState(() {
|
setState(() {
|
||||||
_measureError = hasMeasure
|
_measureError = hasMeasure
|
||||||
? null
|
? null
|
||||||
: 'Active au moins une mesure pour enregistrer.';
|
: '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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -832,6 +1285,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||||||
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
|
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
|
||||||
: null;
|
: null;
|
||||||
|
final steps = _buildSteps();
|
||||||
try {
|
try {
|
||||||
if (exercise == null) {
|
if (exercise == null) {
|
||||||
await widget.exerciseUseCases.create(
|
await widget.exerciseUseCases.create(
|
||||||
@ -850,6 +1304,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
defaultTargetReps: defaultTargetReps,
|
defaultTargetReps: defaultTargetReps,
|
||||||
defaultTargetScore: defaultTargetScore,
|
defaultTargetScore: defaultTargetScore,
|
||||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||||
|
steps: steps,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await widget.exerciseUseCases.update(
|
await widget.exerciseUseCases.update(
|
||||||
@ -869,6 +1324,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
|||||||
defaultTargetReps: defaultTargetReps,
|
defaultTargetReps: defaultTargetReps,
|
||||||
defaultTargetScore: defaultTargetScore,
|
defaultTargetScore: defaultTargetScore,
|
||||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||||
|
steps: steps,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (mounted) {
|
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 {
|
final class MeasureBadge extends StatelessWidget {
|
||||||
const MeasureBadge({
|
const MeasureBadge({
|
||||||
required this.measure,
|
required this.measure,
|
||||||
|
|||||||
@ -1311,6 +1311,8 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
final results = <ActiveSetResult>[];
|
final results = <ActiveSetResult>[];
|
||||||
final restStates = <String, ActiveRestState>{};
|
final restStates = <String, ActiveRestState>{};
|
||||||
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
||||||
|
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
|
||||||
|
final stepResults = <ActiveExerciseStepResult>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ActiveWorkoutSession?> findById(String id) async {
|
Future<ActiveWorkoutSession?> findById(String id) async {
|
||||||
@ -1342,6 +1344,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||||||
|
required String sessionId,
|
||||||
|
required int programIndex,
|
||||||
|
required int exerciseIndex,
|
||||||
|
required int setIndex,
|
||||||
|
}) async {
|
||||||
|
return stepProgressStates.values
|
||||||
|
.where(
|
||||||
|
(state) =>
|
||||||
|
state.activeWorkoutSessionId == sessionId &&
|
||||||
|
state.programIndex == programIndex &&
|
||||||
|
state.exerciseIndex == exerciseIndex &&
|
||||||
|
state.setIndex == setIndex &&
|
||||||
|
state.metadata.deletedAt == null,
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||||||
return restStates.values
|
return restStates.values
|
||||||
@ -1369,6 +1390,24 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return stepProgressStates.values
|
||||||
|
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return stepResults
|
||||||
|
.where((result) => result.activeWorkoutSessionId == sessionId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(ActiveWorkoutSession session) async {
|
Future<void> save(ActiveWorkoutSession session) async {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
@ -1384,6 +1423,29 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
scoreStopwatchStates[state.metadata.id] = state;
|
scoreStopwatchStates[state.metadata.id] = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepProgressState(
|
||||||
|
ActiveExerciseStepProgressState state,
|
||||||
|
) async {
|
||||||
|
stepProgressStates[state.metadata.id] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepResult(
|
||||||
|
ActiveExerciseStepResult result,
|
||||||
|
) async {
|
||||||
|
stepResults.removeWhere(
|
||||||
|
(existing) =>
|
||||||
|
existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
||||||
|
existing.programIndex == result.programIndex &&
|
||||||
|
existing.exerciseIndex == result.exerciseIndex &&
|
||||||
|
existing.setIndex == result.setIndex &&
|
||||||
|
existing.passageIndex == result.passageIndex &&
|
||||||
|
existing.stepIndex == result.stepIndex,
|
||||||
|
);
|
||||||
|
stepResults.add(result);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteScoreStopwatchState({
|
Future<void> deleteScoreStopwatchState({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
|
|||||||
@ -216,6 +216,204 @@ void main() {
|
|||||||
expect(find.text('Aucune image sélectionnée'), findsOneWidget);
|
expect(find.text('Aucune image sélectionnée'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('activer les étapes permet d’ajouter une étape temps', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final exerciseRepository = _FakeExerciseRepository();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 3200));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await _pumpExerciseForm(tester, exerciseRepository);
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Ajouter une étape'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Nom de l’étape').last,
|
||||||
|
'Dribble main droite',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
|
||||||
|
'12',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('1. Dribble main droite'), findsOneWidget);
|
||||||
|
expect(find.text('Temps · 12 s'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ajouter une étape répétitions avec score manuel', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final exerciseRepository = _FakeExerciseRepository();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 3600));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await _pumpExerciseForm(tester, exerciseRepository);
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Ajouter une étape'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(_stepTypeRadio('Répétitions').last);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Nom de l’étape').last,
|
||||||
|
'Pompes',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Répétitions par défaut').last,
|
||||||
|
'10',
|
||||||
|
);
|
||||||
|
await tester.tap(find.widgetWithText(SwitchListTile, 'Score d’étape'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Score à saisir').last,
|
||||||
|
'Réussites',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Unité').last,
|
||||||
|
'paniers',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Score par défaut').last,
|
||||||
|
'0',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('1. Pompes'), findsOneWidget);
|
||||||
|
expect(find.text('Répétitions · 10'), findsOneWidget);
|
||||||
|
expect(find.text('Mode de score'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('réordonner les étapes via Monter Descendre', (tester) async {
|
||||||
|
final exerciseRepository = _FakeExerciseRepository();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 4200));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await _pumpExerciseForm(tester, exerciseRepository);
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Ajouter une étape'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Nom de l’étape').at(0),
|
||||||
|
'Première',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Ajouter une étape'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Nom de l’étape').at(1),
|
||||||
|
'Deuxième',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Actions de l’étape 2'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Monter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('1. Deuxième'), findsOneWidget);
|
||||||
|
expect(find.text('2. Première'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('supprimer une étape demande confirmation', (tester) async {
|
||||||
|
final exerciseRepository = _FakeExerciseRepository();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 3200));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await _pumpExerciseForm(tester, exerciseRepository);
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Ajouter une étape'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Actions de l’étape 1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Supprimer'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Supprimer cette étape ?'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Supprimer'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Nouvelle étape'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('la limite de 8 étapes bloque l’ajout d’une neuvième', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final exerciseRepository = _FakeExerciseRepository();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 8000));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await _pumpExerciseForm(tester, exerciseRepository);
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
for (var index = 0; index < 8; index++) {
|
||||||
|
await tester.tap(find.text('Ajouter une étape'));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(find.text('Limite atteinte'), findsOneWidget);
|
||||||
|
expect(find.text('Un exercice peut contenir jusqu’à 8 étapes.'), findsOneWidget);
|
||||||
|
expect(find.text('Ajouter une étape'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('sauvegarder transmet les étapes au use case', (tester) async {
|
||||||
|
final exerciseRepository = _FakeExerciseRepository();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 3600));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await _pumpExerciseForm(tester, exerciseRepository);
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
|
||||||
|
'30',
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Ajouter une étape'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Nom de l’étape').last,
|
||||||
|
'Dribble main droite',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
|
||||||
|
'12',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.ensureVisible(find.text('Enregistrer'));
|
||||||
|
await tester.tap(find.text('Enregistrer'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final saved = exerciseRepository.saved.single;
|
||||||
|
expect(saved.steps, hasLength(1));
|
||||||
|
expect(saved.steps.single.name, 'Dribble main droite');
|
||||||
|
expect(saved.steps.single.type, ExerciseStepType.time);
|
||||||
|
expect(saved.steps.single.defaultTargetValue, 12);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('désigner une image comme icône l’indique visuellement', (
|
testWidgets('désigner une image comme icône l’indique visuellement', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@ -335,6 +533,30 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpExerciseForm(
|
||||||
|
WidgetTester tester,
|
||||||
|
_FakeExerciseRepository exerciseRepository,
|
||||||
|
) {
|
||||||
|
return tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ExerciseFormScreen(
|
||||||
|
exerciseUseCases: _exerciseUseCases(exerciseRepository),
|
||||||
|
mediaUseCases: _mediaUseCases(exerciseRepository),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder _stepTypeRadio(String title) {
|
||||||
|
return find.byWidgetPredicate(
|
||||||
|
(widget) =>
|
||||||
|
widget is RadioListTile<ExerciseStepType> &&
|
||||||
|
widget.title is Text &&
|
||||||
|
(widget.title as Text).data == title,
|
||||||
|
description: 'RadioListTile<ExerciseStepType> "$title"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
|
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
|
||||||
return ExerciseUseCases(
|
return ExerciseUseCases(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
|
|||||||
@ -274,6 +274,9 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class _FakeWorkoutTemplateRepository
|
final class _FakeWorkoutTemplateRepository
|
||||||
@ -335,6 +338,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
required int setIndex,
|
required int setIndex,
|
||||||
}) async => null;
|
}) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||||||
|
required String sessionId,
|
||||||
|
required int programIndex,
|
||||||
|
required int exerciseIndex,
|
||||||
|
required int setIndex,
|
||||||
|
}) async => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||||||
return const [];
|
return const [];
|
||||||
@ -352,6 +363,20 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(ActiveWorkoutSession session) async {}
|
Future<void> save(ActiveWorkoutSession session) async {}
|
||||||
|
|
||||||
@ -361,6 +386,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepProgressState(
|
||||||
|
ActiveExerciseStepProgressState state,
|
||||||
|
) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteScoreStopwatchState({
|
Future<void> deleteScoreStopwatchState({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
|
|||||||
@ -267,6 +267,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||||||
|
required String sessionId,
|
||||||
|
required int programIndex,
|
||||||
|
required int exerciseIndex,
|
||||||
|
required int setIndex,
|
||||||
|
}) async {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||||||
return restStates;
|
return restStates;
|
||||||
@ -284,6 +294,20 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(ActiveWorkoutSession session) async {
|
Future<void> save(ActiveWorkoutSession session) async {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
@ -299,6 +323,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
scoreStopwatchStates.add(state);
|
scoreStopwatchStates.add(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepProgressState(
|
||||||
|
ActiveExerciseStepProgressState state,
|
||||||
|
) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteScoreStopwatchState({
|
Future<void> deleteScoreStopwatchState({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@ -416,4 +448,7 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1085,6 +1085,8 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
final results = <ActiveSetResult>[];
|
final results = <ActiveSetResult>[];
|
||||||
final restStates = <ActiveRestState>[];
|
final restStates = <ActiveRestState>[];
|
||||||
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
|
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
|
||||||
|
final stepProgressStates = <ActiveExerciseStepProgressState>[];
|
||||||
|
final stepResults = <ActiveExerciseStepResult>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ActiveWorkoutSession?> findById(String id) async {
|
Future<ActiveWorkoutSession?> findById(String id) async {
|
||||||
@ -1123,6 +1125,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||||||
|
required String sessionId,
|
||||||
|
required int programIndex,
|
||||||
|
required int exerciseIndex,
|
||||||
|
required int setIndex,
|
||||||
|
}) async {
|
||||||
|
for (final state in stepProgressStates) {
|
||||||
|
if (state.activeWorkoutSessionId == sessionId &&
|
||||||
|
state.programIndex == programIndex &&
|
||||||
|
state.exerciseIndex == exerciseIndex &&
|
||||||
|
state.setIndex == setIndex &&
|
||||||
|
state.metadata.deletedAt == null) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||||||
return restStates
|
return restStates
|
||||||
@ -1144,6 +1165,24 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return stepProgressStates
|
||||||
|
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return stepResults
|
||||||
|
.where((result) => result.activeWorkoutSessionId == sessionId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(ActiveWorkoutSession session) async {
|
Future<void> save(ActiveWorkoutSession session) async {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
@ -1173,6 +1212,44 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepProgressState(
|
||||||
|
ActiveExerciseStepProgressState state,
|
||||||
|
) async {
|
||||||
|
final index = stepProgressStates.indexWhere(
|
||||||
|
(saved) =>
|
||||||
|
saved.activeWorkoutSessionId == state.activeWorkoutSessionId &&
|
||||||
|
saved.programIndex == state.programIndex &&
|
||||||
|
saved.exerciseIndex == state.exerciseIndex &&
|
||||||
|
saved.setIndex == state.setIndex,
|
||||||
|
);
|
||||||
|
if (index == -1) {
|
||||||
|
stepProgressStates.add(state);
|
||||||
|
} else {
|
||||||
|
stepProgressStates[index] = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepResult(
|
||||||
|
ActiveExerciseStepResult result,
|
||||||
|
) async {
|
||||||
|
final index = stepResults.indexWhere(
|
||||||
|
(saved) =>
|
||||||
|
saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
||||||
|
saved.programIndex == result.programIndex &&
|
||||||
|
saved.exerciseIndex == result.exerciseIndex &&
|
||||||
|
saved.setIndex == result.setIndex &&
|
||||||
|
saved.passageIndex == result.passageIndex &&
|
||||||
|
saved.stepIndex == result.stepIndex,
|
||||||
|
);
|
||||||
|
if (index == -1) {
|
||||||
|
stepResults.add(result);
|
||||||
|
} else {
|
||||||
|
stepResults[index] = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteScoreStopwatchState({
|
Future<void> deleteScoreStopwatchState({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@ -1246,6 +1323,9 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class _FakeProgramRepository implements ProgramRepository {
|
final class _FakeProgramRepository implements ProgramRepository {
|
||||||
|
|||||||
@ -342,6 +342,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||||||
|
required String sessionId,
|
||||||
|
required int programIndex,
|
||||||
|
required int exerciseIndex,
|
||||||
|
required int setIndex,
|
||||||
|
}) async {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||||||
return const [];
|
return const [];
|
||||||
@ -359,6 +369,20 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||||||
|
String sessionId,
|
||||||
|
) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(ActiveWorkoutSession session) async {
|
Future<void> save(ActiveWorkoutSession session) async {
|
||||||
saveCount += 1;
|
saveCount += 1;
|
||||||
@ -371,6 +395,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepProgressState(
|
||||||
|
ActiveExerciseStepProgressState state,
|
||||||
|
) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteScoreStopwatchState({
|
Future<void> deleteScoreStopwatchState({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@ -430,4 +462,7 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user