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> saveRestState(ActiveRestState restState);
|
||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
|
||||
Future<void> saveExerciseStepProgressState(
|
||||
ActiveExerciseStepProgressState state,
|
||||
);
|
||||
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result);
|
||||
Future<void> deleteScoreStopwatchState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
@ -95,7 +99,19 @@ abstract interface class ActiveSessionRepository {
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
});
|
||||
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
});
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId);
|
||||
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||||
String sessionId,
|
||||
);
|
||||
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||||
String sessionId,
|
||||
);
|
||||
Future<List<ActiveRestState>> listRestStates(String sessionId);
|
||||
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
||||
String sessionId,
|
||||
@ -107,6 +123,7 @@ abstract interface class WorkoutHistoryRepository {
|
||||
Future<List<WorkoutHistory>> listActive();
|
||||
Future<void> save(WorkoutHistory history);
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
||||
Future<void> saveStepResult(WorkoutHistoryStepResult result);
|
||||
Future<void> delete(String id, DateTime deletedAt);
|
||||
}
|
||||
|
||||
|
||||
@ -68,6 +68,7 @@ final class ExerciseUseCases {
|
||||
defaultTargetReps: defaultTargetReps,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
steps: steps,
|
||||
);
|
||||
await repository.save(exercise);
|
||||
return exercise;
|
||||
@ -962,6 +963,23 @@ final class ActiveWorkoutSessionUseCases {
|
||||
);
|
||||
await sessionRepository.saveScoreStopwatchState(stopped);
|
||||
}
|
||||
final runningStepTimers =
|
||||
(await sessionRepository.listExerciseStepProgressStates(sessionId))
|
||||
.where(
|
||||
(state) =>
|
||||
state.status ==
|
||||
ActiveExerciseStepProgressStatus.runningTimer,
|
||||
);
|
||||
for (final state in runningStepTimers) {
|
||||
final pausedStep = state.copyWith(
|
||||
metadata: state.metadata.touch(now),
|
||||
status: ActiveExerciseStepProgressStatus.pausedTimer,
|
||||
accumulatedMs: state.elapsedMillisecondsAt(now),
|
||||
startedAt: null,
|
||||
lastTransitionAt: now,
|
||||
);
|
||||
await sessionRepository.saveExerciseStepProgressState(pausedStep);
|
||||
}
|
||||
final paused = session.pause(now);
|
||||
await sessionRepository.save(paused);
|
||||
return paused;
|
||||
@ -1441,6 +1459,555 @@ final class ActiveWorkoutSessionUseCases {
|
||||
}
|
||||
}
|
||||
|
||||
final class ActiveExerciseStepProgressView {
|
||||
const ActiveExerciseStepProgressView({
|
||||
required this.state,
|
||||
required this.steps,
|
||||
required this.expectedPassages,
|
||||
required this.results,
|
||||
});
|
||||
|
||||
final ActiveExerciseStepProgressState state;
|
||||
final List<ExerciseStep> steps;
|
||||
final int expectedPassages;
|
||||
final List<ActiveExerciseStepResult> results;
|
||||
|
||||
ExerciseStep? get currentStep =>
|
||||
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
|
||||
? null
|
||||
: steps[state.currentStepIndex];
|
||||
}
|
||||
|
||||
final class ActiveExerciseStepUseCases {
|
||||
const ActiveExerciseStepUseCases({
|
||||
required this.sessionRepository,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
required this.originDeviceId,
|
||||
});
|
||||
|
||||
final ActiveSessionRepository sessionRepository;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
final String originDeviceId;
|
||||
|
||||
Future<ActiveExerciseStepProgressView> startOrResumeProgress({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final context = await _stepContext(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final now = clock.now();
|
||||
final existing = await sessionRepository.findExerciseStepProgressState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final state = existing ??
|
||||
_initialStepProgressState(
|
||||
sessionId: sessionId,
|
||||
context: context,
|
||||
now: now,
|
||||
);
|
||||
if (existing == null) {
|
||||
await sessionRepository.saveExerciseStepProgressState(state);
|
||||
}
|
||||
final advanced = await _autoAdvanceElapsedTimers(context, state, now);
|
||||
return ActiveExerciseStepProgressView(
|
||||
state: advanced,
|
||||
steps: context.steps,
|
||||
expectedPassages: context.expectedPassages,
|
||||
results: await _stepResultsForPosition(context),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ActiveExerciseStepProgressView> readProgress({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) => startOrResumeProgress(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
|
||||
Future<ActiveExerciseStepProgressState> startTimer({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final view = await startOrResumeProgress(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final step = view.currentStep;
|
||||
if (step == null || step.type != ExerciseStepType.time) {
|
||||
throw const DomainException('Current exercise step is not timed.');
|
||||
}
|
||||
if (view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
|
||||
return view.state;
|
||||
}
|
||||
final now = clock.now();
|
||||
final running = view.state.copyWith(
|
||||
metadata: view.state.metadata.touch(now),
|
||||
status: ActiveExerciseStepProgressStatus.runningTimer,
|
||||
startedAt: now,
|
||||
lastTransitionAt: now,
|
||||
);
|
||||
await sessionRepository.saveExerciseStepProgressState(running);
|
||||
return running;
|
||||
}
|
||||
|
||||
Future<ActiveExerciseStepProgressState> pauseTimer({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final state = await _requiredStepProgressState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
if (state.status != ActiveExerciseStepProgressStatus.runningTimer) {
|
||||
throw const DomainException('Exercise step timer is not running.');
|
||||
}
|
||||
final now = clock.now();
|
||||
final paused = state.copyWith(
|
||||
metadata: state.metadata.touch(now),
|
||||
status: ActiveExerciseStepProgressStatus.pausedTimer,
|
||||
startedAt: null,
|
||||
accumulatedMs: state.elapsedMillisecondsAt(now),
|
||||
lastTransitionAt: now,
|
||||
);
|
||||
await sessionRepository.saveExerciseStepProgressState(paused);
|
||||
return paused;
|
||||
}
|
||||
|
||||
Future<ActiveExerciseStepProgressState> resumeTimer({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) {
|
||||
return startTimer(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
}
|
||||
|
||||
int elapsedMilliseconds(ActiveExerciseStepProgressState state) {
|
||||
return state.elapsedMillisecondsAt(clock.now());
|
||||
}
|
||||
|
||||
int remainingMilliseconds({
|
||||
required ActiveExerciseStepProgressState state,
|
||||
required ExerciseStep step,
|
||||
}) {
|
||||
final targetMs = step.defaultTargetValue * 1000;
|
||||
final remaining = targetMs - elapsedMilliseconds(state);
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
Future<ActiveExerciseStepResult> completeCurrentStep({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
int? actualReps,
|
||||
double? actualScore,
|
||||
int? actualScoreTimeMs,
|
||||
String? note,
|
||||
}) async {
|
||||
final context = await _stepContext(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final now = clock.now();
|
||||
final state = await _autoAdvanceElapsedTimers(
|
||||
context,
|
||||
await _requiredStepProgressState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
),
|
||||
now,
|
||||
);
|
||||
final step = _currentStep(context, state);
|
||||
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
|
||||
throw const DomainException('Exercise step sequence is complete.');
|
||||
}
|
||||
final completed = _stepResult(
|
||||
context: context,
|
||||
state: state,
|
||||
step: step,
|
||||
status: SetResultStatus.completed,
|
||||
now: now,
|
||||
actualTimeMs: step.type == ExerciseStepType.time
|
||||
? state.elapsedMillisecondsAt(now)
|
||||
: null,
|
||||
actualReps: step.type == ExerciseStepType.reps
|
||||
? actualReps ?? step.defaultTargetValue
|
||||
: null,
|
||||
actualScore: actualScore,
|
||||
actualScoreTimeMs: actualScoreTimeMs,
|
||||
note: note,
|
||||
);
|
||||
await sessionRepository.saveExerciseStepResult(completed);
|
||||
await sessionRepository.saveExerciseStepProgressState(
|
||||
_advanceState(context, state, now),
|
||||
);
|
||||
return completed;
|
||||
}
|
||||
|
||||
Future<ActiveExerciseStepResult> skipCurrentStep({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final context = await _stepContext(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final state = await _requiredStepProgressState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final now = clock.now();
|
||||
final step = _currentStep(context, state);
|
||||
final skipped = _stepResult(
|
||||
context: context,
|
||||
state: state,
|
||||
step: step,
|
||||
status: SetResultStatus.skipped,
|
||||
now: now,
|
||||
);
|
||||
await sessionRepository.saveExerciseStepResult(skipped);
|
||||
await sessionRepository.saveExerciseStepProgressState(
|
||||
_advanceState(context, state, now),
|
||||
);
|
||||
return skipped;
|
||||
}
|
||||
|
||||
Future<List<ActiveExerciseStepResult>> skipCurrentPassage({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final context = await _stepContext(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
var state = await _requiredStepProgressState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final now = clock.now();
|
||||
final skipped = <ActiveExerciseStepResult>[];
|
||||
final passage = state.currentPassageIndex;
|
||||
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete &&
|
||||
state.currentPassageIndex == passage) {
|
||||
final step = _currentStep(context, state);
|
||||
final result = _stepResult(
|
||||
context: context,
|
||||
state: state,
|
||||
step: step,
|
||||
status: SetResultStatus.skipped,
|
||||
now: now,
|
||||
);
|
||||
skipped.add(result);
|
||||
await sessionRepository.saveExerciseStepResult(result);
|
||||
state = _advanceState(context, state, now);
|
||||
}
|
||||
await sessionRepository.saveExerciseStepProgressState(state);
|
||||
return skipped;
|
||||
}
|
||||
|
||||
Future<List<ActiveExerciseStepResult>> skipSequence({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final context = await _stepContext(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
var state = await _requiredStepProgressState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
final now = clock.now();
|
||||
final skipped = <ActiveExerciseStepResult>[];
|
||||
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete) {
|
||||
final step = _currentStep(context, state);
|
||||
final result = _stepResult(
|
||||
context: context,
|
||||
state: state,
|
||||
step: step,
|
||||
status: SetResultStatus.skipped,
|
||||
now: now,
|
||||
);
|
||||
skipped.add(result);
|
||||
await sessionRepository.saveExerciseStepResult(result);
|
||||
state = _advanceState(context, state, now);
|
||||
}
|
||||
await sessionRepository.saveExerciseStepProgressState(state);
|
||||
return skipped;
|
||||
}
|
||||
|
||||
Future<ActiveExerciseStepProgressState> _requiredStepProgressState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final state = await sessionRepository.findExerciseStepProgressState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
if (state == null) {
|
||||
throw const DomainException('Exercise step progress state not found.');
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
Future<_StepSequenceContext> _stepContext({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final session = await sessionRepository.findById(sessionId);
|
||||
if (session == null) {
|
||||
throw const DomainException('Active workout session not found.');
|
||||
}
|
||||
final snapshot = _findExerciseSnapshot(
|
||||
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
);
|
||||
if (snapshot == null || snapshot.steps.isEmpty) {
|
||||
throw const DomainException('Exercise step sequence not found.');
|
||||
}
|
||||
return _StepSequenceContext(
|
||||
sessionId: sessionId,
|
||||
programSnapshotId: snapshot.programSnapshotId,
|
||||
exerciseSnapshotId: snapshot.exerciseSnapshotId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
steps: snapshot.steps,
|
||||
expectedPassages: snapshot.repsEnabled
|
||||
? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
ActiveExerciseStepProgressState _initialStepProgressState({
|
||||
required String sessionId,
|
||||
required _StepSequenceContext context,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final firstStep = context.steps.first;
|
||||
return ActiveExerciseStepProgressState(
|
||||
metadata: _newMetadata(ids, originDeviceId, now),
|
||||
activeWorkoutSessionId: sessionId,
|
||||
programIndex: context.programIndex,
|
||||
exerciseIndex: context.exerciseIndex,
|
||||
setIndex: context.setIndex,
|
||||
currentPassageIndex: 0,
|
||||
currentStepIndex: 0,
|
||||
currentStepSnapshotId: firstStep.id,
|
||||
status: firstStep.type == ExerciseStepType.time
|
||||
? ActiveExerciseStepProgressStatus.stoppedTimer
|
||||
: ActiveExerciseStepProgressStatus.waitingManual,
|
||||
accumulatedMs: 0,
|
||||
lastTransitionAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ActiveExerciseStepProgressState> _autoAdvanceElapsedTimers(
|
||||
_StepSequenceContext context,
|
||||
ActiveExerciseStepProgressState state,
|
||||
DateTime now,
|
||||
) async {
|
||||
var current = state;
|
||||
while (current.status == ActiveExerciseStepProgressStatus.runningTimer) {
|
||||
final step = _currentStep(context, current);
|
||||
if (step.type != ExerciseStepType.time) {
|
||||
break;
|
||||
}
|
||||
final targetMs = step.defaultTargetValue * 1000;
|
||||
final elapsed = current.elapsedMillisecondsAt(now);
|
||||
if (elapsed < targetMs) {
|
||||
break;
|
||||
}
|
||||
final result = _stepResult(
|
||||
context: context,
|
||||
state: current,
|
||||
step: step,
|
||||
status: SetResultStatus.completed,
|
||||
now: now,
|
||||
actualTimeMs: targetMs,
|
||||
);
|
||||
await sessionRepository.saveExerciseStepResult(result);
|
||||
final overflowMs = elapsed - targetMs;
|
||||
current = _advanceState(
|
||||
context,
|
||||
current,
|
||||
now,
|
||||
runningTimerOverflowMs: overflowMs,
|
||||
);
|
||||
await sessionRepository.saveExerciseStepProgressState(current);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
ActiveExerciseStepProgressState _advanceState(
|
||||
_StepSequenceContext context,
|
||||
ActiveExerciseStepProgressState state,
|
||||
DateTime now, {
|
||||
int runningTimerOverflowMs = 0,
|
||||
}) {
|
||||
var nextPassage = state.currentPassageIndex;
|
||||
var nextStep = state.currentStepIndex + 1;
|
||||
if (nextStep >= context.steps.length) {
|
||||
nextStep = 0;
|
||||
nextPassage++;
|
||||
}
|
||||
if (nextPassage >= context.expectedPassages) {
|
||||
return state.copyWith(
|
||||
metadata: state.metadata.touch(now),
|
||||
status: ActiveExerciseStepProgressStatus.sequenceComplete,
|
||||
startedAt: null,
|
||||
accumulatedMs: 0,
|
||||
lastTransitionAt: now,
|
||||
);
|
||||
}
|
||||
final step = context.steps[nextStep];
|
||||
final status = step.type == ExerciseStepType.time
|
||||
? runningTimerOverflowMs > 0
|
||||
? ActiveExerciseStepProgressStatus.runningTimer
|
||||
: ActiveExerciseStepProgressStatus.stoppedTimer
|
||||
: ActiveExerciseStepProgressStatus.waitingManual;
|
||||
return state.copyWith(
|
||||
metadata: state.metadata.touch(now),
|
||||
currentPassageIndex: nextPassage,
|
||||
currentStepIndex: nextStep,
|
||||
currentStepSnapshotId: step.id,
|
||||
status: status,
|
||||
startedAt: status == ActiveExerciseStepProgressStatus.runningTimer
|
||||
? now.subtract(Duration(milliseconds: runningTimerOverflowMs))
|
||||
: null,
|
||||
accumulatedMs: 0,
|
||||
lastTransitionAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
ExerciseStep _currentStep(
|
||||
_StepSequenceContext context,
|
||||
ActiveExerciseStepProgressState state,
|
||||
) {
|
||||
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
|
||||
throw const DomainException('Exercise step sequence is complete.');
|
||||
}
|
||||
return context.steps[state.currentStepIndex];
|
||||
}
|
||||
|
||||
Future<List<ActiveExerciseStepResult>> _stepResultsForPosition(
|
||||
_StepSequenceContext context,
|
||||
) async {
|
||||
return (await sessionRepository.listExerciseStepResults(context.sessionId))
|
||||
.where(
|
||||
(result) =>
|
||||
result.programIndex == context.programIndex &&
|
||||
result.exerciseIndex == context.exerciseIndex &&
|
||||
result.setIndex == context.setIndex,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
ActiveExerciseStepResult _stepResult({
|
||||
required _StepSequenceContext context,
|
||||
required ActiveExerciseStepProgressState state,
|
||||
required ExerciseStep step,
|
||||
required SetResultStatus status,
|
||||
required DateTime now,
|
||||
int? actualTimeMs,
|
||||
int? actualReps,
|
||||
double? actualScore,
|
||||
int? actualScoreTimeMs,
|
||||
String? note,
|
||||
}) {
|
||||
return ActiveExerciseStepResult(
|
||||
metadata: _newMetadata(ids, originDeviceId, now),
|
||||
activeWorkoutSessionId: context.sessionId,
|
||||
programSnapshotId: context.programSnapshotId,
|
||||
exerciseSnapshotId: context.exerciseSnapshotId,
|
||||
programIndex: context.programIndex,
|
||||
exerciseIndex: context.exerciseIndex,
|
||||
setIndex: context.setIndex,
|
||||
passageIndex: state.currentPassageIndex,
|
||||
stepIndex: state.currentStepIndex,
|
||||
stepSnapshotId: step.id,
|
||||
stepNameSnapshot: step.name,
|
||||
stepTypeSnapshot: step.type,
|
||||
targetValueSnapshot: step.defaultTargetValue,
|
||||
hasScoreSnapshot: step.hasScore,
|
||||
scoreInputModeSnapshot: step.hasScore ? step.scoreInputMode : null,
|
||||
scoreLabelSnapshot: step.scoreLabel,
|
||||
scoreUnitSnapshot: step.scoreUnit,
|
||||
targetScoreSnapshot: step.defaultTargetScore,
|
||||
targetScoreTimeMsSnapshot: step.defaultTargetScoreTimeMs,
|
||||
status: status,
|
||||
startedAt: state.startedAt,
|
||||
completedAt: status == SetResultStatus.completed ? now : null,
|
||||
actualTimeMs: status == SetResultStatus.completed ? actualTimeMs : null,
|
||||
actualReps: status == SetResultStatus.completed ? actualReps : null,
|
||||
actualScore: status == SetResultStatus.completed ? actualScore : null,
|
||||
actualScoreTimeMs: status == SetResultStatus.completed
|
||||
? actualScoreTimeMs
|
||||
: null,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class CloseWorkoutSessionUseCase {
|
||||
const CloseWorkoutSessionUseCase({
|
||||
required this.sessionRepository,
|
||||
@ -1467,6 +2034,9 @@ final class CloseWorkoutSessionUseCase {
|
||||
}
|
||||
final now = clock.now();
|
||||
final results = await sessionRepository.listSetResults(sessionId);
|
||||
final stepResults = await sessionRepository.listExerciseStepResults(
|
||||
sessionId,
|
||||
);
|
||||
final historyId = ids.newId();
|
||||
final historyResults = _historyResultsFromActiveResults(
|
||||
historyId: historyId,
|
||||
@ -1476,6 +2046,13 @@ final class CloseWorkoutSessionUseCase {
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
);
|
||||
final historyStepResults = _historyStepResultsFromActiveResults(
|
||||
historyId: historyId,
|
||||
results: stepResults,
|
||||
now: now,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
);
|
||||
final history = WorkoutHistory(
|
||||
metadata: EntityMetadata(
|
||||
id: historyId,
|
||||
@ -1512,8 +2089,38 @@ final class CloseWorkoutSessionUseCase {
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'stepResults': stepResults
|
||||
.map(
|
||||
(result) => {
|
||||
'programSnapshotId': result.programSnapshotId,
|
||||
'exerciseSnapshotId': result.exerciseSnapshotId,
|
||||
'programIndex': result.programIndex,
|
||||
'exerciseIndex': result.exerciseIndex,
|
||||
'setIndex': result.setIndex,
|
||||
'passageIndex': result.passageIndex,
|
||||
'stepIndex': result.stepIndex,
|
||||
'stepSnapshotId': result.stepSnapshotId,
|
||||
'stepNameSnapshot': result.stepNameSnapshot,
|
||||
'stepTypeSnapshot': result.stepTypeSnapshot.name,
|
||||
'targetValueSnapshot': result.targetValueSnapshot,
|
||||
'hasScoreSnapshot': result.hasScoreSnapshot,
|
||||
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
|
||||
'scoreLabelSnapshot': result.scoreLabelSnapshot,
|
||||
'scoreUnitSnapshot': result.scoreUnitSnapshot,
|
||||
'targetScoreSnapshot': result.targetScoreSnapshot,
|
||||
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
|
||||
'status': result.status.name,
|
||||
'actualTimeMs': result.actualTimeMs,
|
||||
'actualReps': result.actualReps,
|
||||
'actualScore': result.actualScore,
|
||||
'actualScoreTimeMs': result.actualScoreTimeMs,
|
||||
'note': result.note,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
}),
|
||||
results: historyResults,
|
||||
stepResults: historyStepResults,
|
||||
);
|
||||
await historyRepository.save(history);
|
||||
return history;
|
||||
@ -1867,6 +2474,53 @@ _SetPositionSnapshot? _findSetSnapshot({
|
||||
return null;
|
||||
}
|
||||
|
||||
_ResolvedExerciseSnapshot? _findExerciseSnapshot({
|
||||
required String resolvedTemplateSnapshotJson,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
}) {
|
||||
final decoded =
|
||||
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
|
||||
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
|
||||
if (programIndex < 0 || programIndex >= programs.length) {
|
||||
return null;
|
||||
}
|
||||
final program = programs[programIndex] as Map<String, dynamic>;
|
||||
final programSnapshotId = program['id'] as String? ?? 'program-$programIndex';
|
||||
final programName = program['programNameSnapshot'] as String? ?? '';
|
||||
final programSnapshotJson = program['programSnapshotJson'] as String?;
|
||||
if (programSnapshotJson == null) {
|
||||
return null;
|
||||
}
|
||||
final programSnapshot =
|
||||
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
||||
final exercises = (programSnapshot['exercises'] as List<dynamic>? ?? const []);
|
||||
if (exerciseIndex < 0 || exerciseIndex >= exercises.length) {
|
||||
return null;
|
||||
}
|
||||
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
|
||||
final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex';
|
||||
return _ResolvedExerciseSnapshot(
|
||||
programSnapshotId: programSnapshotId,
|
||||
exerciseSnapshotId: id,
|
||||
programNameSnapshot: programName,
|
||||
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
|
||||
timeEnabled: exercise['timeEnabled'] == true,
|
||||
repsEnabled: exercise['repsEnabled'] == true,
|
||||
scoreEnabled: exercise['scoreEnabled'] == true,
|
||||
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
|
||||
targetReps: exercise['targetReps'] as int?,
|
||||
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
|
||||
targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
|
||||
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
||||
exercise['scoreInputModeSnapshot'],
|
||||
),
|
||||
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
||||
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
||||
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
||||
);
|
||||
}
|
||||
|
||||
List<_SetPositionSnapshot> _listSetSnapshots(
|
||||
String resolvedTemplateSnapshotJson,
|
||||
) {
|
||||
@ -1938,6 +2592,28 @@ final class _SetPositionSnapshot {
|
||||
final ScoreInputMode scoreInputModeSnapshot;
|
||||
}
|
||||
|
||||
final class _StepSequenceContext {
|
||||
const _StepSequenceContext({
|
||||
required this.sessionId,
|
||||
required this.programSnapshotId,
|
||||
required this.exerciseSnapshotId,
|
||||
required this.programIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.setIndex,
|
||||
required this.steps,
|
||||
required this.expectedPassages,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final String programSnapshotId;
|
||||
final String exerciseSnapshotId;
|
||||
final int programIndex;
|
||||
final int exerciseIndex;
|
||||
final int setIndex;
|
||||
final List<ExerciseStep> steps;
|
||||
final int expectedPassages;
|
||||
}
|
||||
|
||||
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
|
||||
required String historyId,
|
||||
required List<ActiveSetResult> results,
|
||||
@ -2010,6 +2686,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
||||
continue;
|
||||
}
|
||||
snapshots[id] = _ResolvedExerciseSnapshot(
|
||||
programSnapshotId: program['id'] as String? ?? id,
|
||||
exerciseSnapshotId: id,
|
||||
programNameSnapshot: programName,
|
||||
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
|
||||
timeEnabled: exercise['timeEnabled'] == true,
|
||||
@ -2024,6 +2702,7 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
||||
),
|
||||
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
||||
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
||||
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2032,6 +2711,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
||||
|
||||
final class _ResolvedExerciseSnapshot {
|
||||
const _ResolvedExerciseSnapshot({
|
||||
required this.programSnapshotId,
|
||||
required this.exerciseSnapshotId,
|
||||
required this.programNameSnapshot,
|
||||
required this.exerciseNameSnapshot,
|
||||
required this.timeEnabled,
|
||||
@ -2044,8 +2725,11 @@ final class _ResolvedExerciseSnapshot {
|
||||
required this.scoreInputModeSnapshot,
|
||||
this.scoreLabelSnapshot,
|
||||
this.scoreUnitSnapshot,
|
||||
this.steps = const [],
|
||||
});
|
||||
|
||||
final String programSnapshotId;
|
||||
final String exerciseSnapshotId;
|
||||
final String programNameSnapshot;
|
||||
final String exerciseNameSnapshot;
|
||||
final bool timeEnabled;
|
||||
@ -2058,6 +2742,73 @@ final class _ResolvedExerciseSnapshot {
|
||||
final ScoreInputMode scoreInputModeSnapshot;
|
||||
final String? scoreLabelSnapshot;
|
||||
final String? scoreUnitSnapshot;
|
||||
final List<ExerciseStep> steps;
|
||||
}
|
||||
|
||||
List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
return value.map((item) {
|
||||
final json = Map<String, dynamic>.from(item as Map);
|
||||
return ExerciseStep(
|
||||
id: json['id'] as String,
|
||||
position: json['position'] as int,
|
||||
name: json['name'] as String,
|
||||
type: switch (json['type']) {
|
||||
'time' => ExerciseStepType.time,
|
||||
'reps' => ExerciseStepType.reps,
|
||||
_ => throw const DomainException('Unknown exercise step type.'),
|
||||
},
|
||||
defaultTargetValue: json['defaultTargetValue'] as int,
|
||||
hasScore: json['hasScore'] == true,
|
||||
scoreInputMode: _scoreInputModeFromSnapshot(json['scoreInputMode']),
|
||||
scoreLabel: json['scoreLabel'] as String?,
|
||||
scoreUnit: json['scoreUnit'] as String?,
|
||||
defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(),
|
||||
defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?,
|
||||
);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
List<WorkoutHistoryStepResult> _historyStepResultsFromActiveResults({
|
||||
required String historyId,
|
||||
required List<ActiveExerciseStepResult> results,
|
||||
required DateTime now,
|
||||
required IdGenerator ids,
|
||||
required String originDeviceId,
|
||||
}) {
|
||||
return results.map((result) {
|
||||
return WorkoutHistoryStepResult(
|
||||
metadata: _newMetadata(ids, originDeviceId, now),
|
||||
workoutHistoryId: historyId,
|
||||
programSnapshotId: result.programSnapshotId,
|
||||
exerciseSnapshotId: result.exerciseSnapshotId,
|
||||
programIndex: result.programIndex,
|
||||
exerciseIndex: result.exerciseIndex,
|
||||
setIndex: result.setIndex,
|
||||
passageIndex: result.passageIndex,
|
||||
stepIndex: result.stepIndex,
|
||||
stepSnapshotId: result.stepSnapshotId,
|
||||
stepNameSnapshot: result.stepNameSnapshot,
|
||||
stepTypeSnapshot: result.stepTypeSnapshot,
|
||||
targetValueSnapshot: result.targetValueSnapshot,
|
||||
hasScoreSnapshot: result.hasScoreSnapshot,
|
||||
scoreInputModeSnapshot: result.scoreInputModeSnapshot,
|
||||
scoreLabelSnapshot: result.scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: result.scoreUnitSnapshot,
|
||||
targetScoreSnapshot: result.targetScoreSnapshot,
|
||||
targetScoreTimeMsSnapshot: result.targetScoreTimeMsSnapshot,
|
||||
status: result.status,
|
||||
startedAt: result.startedAt,
|
||||
completedAt: result.completedAt,
|
||||
actualTimeMs: result.actualTimeMs,
|
||||
actualReps: result.actualReps,
|
||||
actualScore: result.actualScore,
|
||||
actualScoreTimeMs: result.actualScoreTimeMs,
|
||||
note: result.note,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
EntityMetadata _newMetadata(
|
||||
|
||||
@ -16,6 +16,15 @@ enum SetResultStatus { completed, skipped }
|
||||
|
||||
enum ActiveScoreStopwatchStatus { running, stopped }
|
||||
|
||||
enum ActiveExerciseStepProgressStatus {
|
||||
notStarted,
|
||||
waitingManual,
|
||||
runningTimer,
|
||||
pausedTimer,
|
||||
stoppedTimer,
|
||||
sequenceComplete,
|
||||
}
|
||||
|
||||
final class DomainException implements Exception {
|
||||
const DomainException(this.message);
|
||||
|
||||
@ -916,6 +925,166 @@ final class ActiveRestState {
|
||||
}
|
||||
}
|
||||
|
||||
final class ActiveExerciseStepProgressState {
|
||||
ActiveExerciseStepProgressState({
|
||||
required this.metadata,
|
||||
required this.activeWorkoutSessionId,
|
||||
required this.programIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.setIndex,
|
||||
required this.currentPassageIndex,
|
||||
required this.currentStepIndex,
|
||||
required this.currentStepSnapshotId,
|
||||
required this.status,
|
||||
this.startedAt,
|
||||
required this.accumulatedMs,
|
||||
required this.lastTransitionAt,
|
||||
}) {
|
||||
_requireNonNegative(programIndex, 'Program index');
|
||||
_requireNonNegative(exerciseIndex, 'Exercise index');
|
||||
_requireNonNegative(setIndex, 'Set index');
|
||||
_requireNonNegative(currentPassageIndex, 'Current passage index');
|
||||
_requireNonNegative(currentStepIndex, 'Current step index');
|
||||
_nonBlank(currentStepSnapshotId, 'Current step snapshot id');
|
||||
_requireNonNegative(accumulatedMs, 'Accumulated milliseconds');
|
||||
if (status == ActiveExerciseStepProgressStatus.runningTimer &&
|
||||
startedAt == null) {
|
||||
throw const DomainException(
|
||||
'Running exercise step timer requires a start timestamp.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String activeWorkoutSessionId;
|
||||
final int programIndex;
|
||||
final int exerciseIndex;
|
||||
final int setIndex;
|
||||
final int currentPassageIndex;
|
||||
final int currentStepIndex;
|
||||
final String currentStepSnapshotId;
|
||||
final ActiveExerciseStepProgressStatus status;
|
||||
final DateTime? startedAt;
|
||||
final int accumulatedMs;
|
||||
final DateTime lastTransitionAt;
|
||||
|
||||
int elapsedMillisecondsAt(DateTime now) {
|
||||
if (status != ActiveExerciseStepProgressStatus.runningTimer ||
|
||||
startedAt == null) {
|
||||
return accumulatedMs;
|
||||
}
|
||||
return accumulatedMs + now.difference(startedAt!).inMilliseconds;
|
||||
}
|
||||
|
||||
ActiveExerciseStepProgressState copyWith({
|
||||
EntityMetadata? metadata,
|
||||
int? currentPassageIndex,
|
||||
int? currentStepIndex,
|
||||
String? currentStepSnapshotId,
|
||||
ActiveExerciseStepProgressStatus? status,
|
||||
Object? startedAt = _unchanged,
|
||||
int? accumulatedMs,
|
||||
DateTime? lastTransitionAt,
|
||||
}) {
|
||||
return ActiveExerciseStepProgressState(
|
||||
metadata: metadata ?? this.metadata,
|
||||
activeWorkoutSessionId: activeWorkoutSessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
currentPassageIndex: currentPassageIndex ?? this.currentPassageIndex,
|
||||
currentStepIndex: currentStepIndex ?? this.currentStepIndex,
|
||||
currentStepSnapshotId:
|
||||
currentStepSnapshotId ?? this.currentStepSnapshotId,
|
||||
status: status ?? this.status,
|
||||
startedAt: startedAt == _unchanged ? this.startedAt : startedAt as DateTime?,
|
||||
accumulatedMs: accumulatedMs ?? this.accumulatedMs,
|
||||
lastTransitionAt: lastTransitionAt ?? this.lastTransitionAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class ActiveExerciseStepResult {
|
||||
ActiveExerciseStepResult({
|
||||
required this.metadata,
|
||||
required this.activeWorkoutSessionId,
|
||||
required this.programSnapshotId,
|
||||
required this.exerciseSnapshotId,
|
||||
required this.programIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.setIndex,
|
||||
required this.passageIndex,
|
||||
required this.stepIndex,
|
||||
required this.stepSnapshotId,
|
||||
required this.stepNameSnapshot,
|
||||
required this.stepTypeSnapshot,
|
||||
required this.targetValueSnapshot,
|
||||
required this.hasScoreSnapshot,
|
||||
this.scoreInputModeSnapshot,
|
||||
this.scoreLabelSnapshot,
|
||||
this.scoreUnitSnapshot,
|
||||
this.targetScoreSnapshot,
|
||||
this.targetScoreTimeMsSnapshot,
|
||||
required this.status,
|
||||
this.startedAt,
|
||||
this.completedAt,
|
||||
this.actualTimeMs,
|
||||
this.actualReps,
|
||||
this.actualScore,
|
||||
this.actualScoreTimeMs,
|
||||
this.note,
|
||||
}) {
|
||||
_validateExerciseStepResult(
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
stepTypeSnapshot: stepTypeSnapshot,
|
||||
targetValueSnapshot: targetValueSnapshot,
|
||||
hasScoreSnapshot: hasScoreSnapshot,
|
||||
scoreInputModeSnapshot: scoreInputModeSnapshot,
|
||||
scoreLabelSnapshot: scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: scoreUnitSnapshot,
|
||||
targetScoreSnapshot: targetScoreSnapshot,
|
||||
targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot,
|
||||
status: status,
|
||||
actualTimeMs: actualTimeMs,
|
||||
actualReps: actualReps,
|
||||
actualScore: actualScore,
|
||||
actualScoreTimeMs: actualScoreTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String activeWorkoutSessionId;
|
||||
final String programSnapshotId;
|
||||
final String exerciseSnapshotId;
|
||||
final int programIndex;
|
||||
final int exerciseIndex;
|
||||
final int setIndex;
|
||||
final int passageIndex;
|
||||
final int stepIndex;
|
||||
final String stepSnapshotId;
|
||||
final String stepNameSnapshot;
|
||||
final ExerciseStepType stepTypeSnapshot;
|
||||
final int targetValueSnapshot;
|
||||
final bool hasScoreSnapshot;
|
||||
final ScoreInputMode? scoreInputModeSnapshot;
|
||||
final String? scoreLabelSnapshot;
|
||||
final String? scoreUnitSnapshot;
|
||||
final double? targetScoreSnapshot;
|
||||
final int? targetScoreTimeMsSnapshot;
|
||||
final SetResultStatus status;
|
||||
final DateTime? startedAt;
|
||||
final DateTime? completedAt;
|
||||
final int? actualTimeMs;
|
||||
final int? actualReps;
|
||||
final double? actualScore;
|
||||
final int? actualScoreTimeMs;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
final class WorkoutHistory {
|
||||
const WorkoutHistory({
|
||||
required this.metadata,
|
||||
@ -928,6 +1097,7 @@ final class WorkoutHistory {
|
||||
required this.completed,
|
||||
required this.historySnapshotJson,
|
||||
this.results = const [],
|
||||
this.stepResults = const [],
|
||||
});
|
||||
|
||||
final EntityMetadata metadata;
|
||||
@ -940,6 +1110,7 @@ final class WorkoutHistory {
|
||||
final bool completed;
|
||||
final String historySnapshotJson;
|
||||
final List<WorkoutHistorySetResult> results;
|
||||
final List<WorkoutHistoryStepResult> stepResults;
|
||||
}
|
||||
|
||||
final class WorkoutHistorySetResult {
|
||||
@ -1021,6 +1192,87 @@ final class WorkoutHistorySetResult {
|
||||
final SetResultStatus status;
|
||||
}
|
||||
|
||||
final class WorkoutHistoryStepResult {
|
||||
WorkoutHistoryStepResult({
|
||||
required this.metadata,
|
||||
required this.workoutHistoryId,
|
||||
required this.programSnapshotId,
|
||||
required this.exerciseSnapshotId,
|
||||
required this.programIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.setIndex,
|
||||
required this.passageIndex,
|
||||
required this.stepIndex,
|
||||
required this.stepSnapshotId,
|
||||
required this.stepNameSnapshot,
|
||||
required this.stepTypeSnapshot,
|
||||
required this.targetValueSnapshot,
|
||||
required this.hasScoreSnapshot,
|
||||
this.scoreInputModeSnapshot,
|
||||
this.scoreLabelSnapshot,
|
||||
this.scoreUnitSnapshot,
|
||||
this.targetScoreSnapshot,
|
||||
this.targetScoreTimeMsSnapshot,
|
||||
required this.status,
|
||||
this.startedAt,
|
||||
this.completedAt,
|
||||
this.actualTimeMs,
|
||||
this.actualReps,
|
||||
this.actualScore,
|
||||
this.actualScoreTimeMs,
|
||||
this.note,
|
||||
}) {
|
||||
_validateExerciseStepResult(
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
stepTypeSnapshot: stepTypeSnapshot,
|
||||
targetValueSnapshot: targetValueSnapshot,
|
||||
hasScoreSnapshot: hasScoreSnapshot,
|
||||
scoreInputModeSnapshot: scoreInputModeSnapshot,
|
||||
scoreLabelSnapshot: scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: scoreUnitSnapshot,
|
||||
targetScoreSnapshot: targetScoreSnapshot,
|
||||
targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot,
|
||||
status: status,
|
||||
actualTimeMs: actualTimeMs,
|
||||
actualReps: actualReps,
|
||||
actualScore: actualScore,
|
||||
actualScoreTimeMs: actualScoreTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String workoutHistoryId;
|
||||
final String programSnapshotId;
|
||||
final String exerciseSnapshotId;
|
||||
final int programIndex;
|
||||
final int exerciseIndex;
|
||||
final int setIndex;
|
||||
final int passageIndex;
|
||||
final int stepIndex;
|
||||
final String stepSnapshotId;
|
||||
final String stepNameSnapshot;
|
||||
final ExerciseStepType stepTypeSnapshot;
|
||||
final int targetValueSnapshot;
|
||||
final bool hasScoreSnapshot;
|
||||
final ScoreInputMode? scoreInputModeSnapshot;
|
||||
final String? scoreLabelSnapshot;
|
||||
final String? scoreUnitSnapshot;
|
||||
final double? targetScoreSnapshot;
|
||||
final int? targetScoreTimeMsSnapshot;
|
||||
final SetResultStatus status;
|
||||
final DateTime? startedAt;
|
||||
final DateTime? completedAt;
|
||||
final int? actualTimeMs;
|
||||
final int? actualReps;
|
||||
final double? actualScore;
|
||||
final int? actualScoreTimeMs;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
String _nonBlank(String? value, String label) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) {
|
||||
@ -1222,3 +1474,67 @@ void _requireScoreResultShape({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _validateExerciseStepResult({
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required int passageIndex,
|
||||
required int stepIndex,
|
||||
required ExerciseStepType stepTypeSnapshot,
|
||||
required int targetValueSnapshot,
|
||||
required bool hasScoreSnapshot,
|
||||
required ScoreInputMode? scoreInputModeSnapshot,
|
||||
required String? scoreLabelSnapshot,
|
||||
required String? scoreUnitSnapshot,
|
||||
required double? targetScoreSnapshot,
|
||||
required int? targetScoreTimeMsSnapshot,
|
||||
required SetResultStatus status,
|
||||
required int? actualTimeMs,
|
||||
required int? actualReps,
|
||||
required double? actualScore,
|
||||
required int? actualScoreTimeMs,
|
||||
}) {
|
||||
_requireNonNegative(programIndex, 'Program index');
|
||||
_requireNonNegative(exerciseIndex, 'Exercise index');
|
||||
_requireNonNegative(setIndex, 'Set index');
|
||||
_requireNonNegative(passageIndex, 'Passage index');
|
||||
_requireNonNegative(stepIndex, 'Step index');
|
||||
_requirePositive(targetValueSnapshot, 'Step target value snapshot');
|
||||
_requireNullableNonNegative(actualTimeMs, 'Actual time milliseconds');
|
||||
_requireNullableNonNegative(actualReps, 'Actual reps');
|
||||
_requireNullableNonNegativeDouble(actualScore, 'Actual score');
|
||||
_requireNullableNonNegative(actualScoreTimeMs, 'Actual score time ms');
|
||||
if (status == SetResultStatus.skipped &&
|
||||
(actualTimeMs != null ||
|
||||
actualReps != null ||
|
||||
actualScore != null ||
|
||||
actualScoreTimeMs != null)) {
|
||||
throw const DomainException('Skipped step results cannot have values.');
|
||||
}
|
||||
if (actualTimeMs != null && stepTypeSnapshot != ExerciseStepType.time) {
|
||||
throw const DomainException('Actual time requires a time step.');
|
||||
}
|
||||
if (actualReps != null && stepTypeSnapshot != ExerciseStepType.reps) {
|
||||
throw const DomainException('Actual reps requires a reps step.');
|
||||
}
|
||||
final mode = scoreInputModeSnapshot ?? ScoreInputMode.manual;
|
||||
_requireScoreTargetShape(
|
||||
scoreEnabled: hasScoreSnapshot,
|
||||
scoreInputMode: mode,
|
||||
targetScore: targetScoreSnapshot,
|
||||
targetScoreTimeMs: targetScoreTimeMsSnapshot,
|
||||
);
|
||||
_requireScoreResultShape(
|
||||
scoreInputMode: mode,
|
||||
actualScore: actualScore,
|
||||
actualScoreTimeMs: actualScoreTimeMs,
|
||||
);
|
||||
if (!hasScoreSnapshot && (actualScore != null || actualScoreTimeMs != null)) {
|
||||
throw const DomainException('Step score result requires active score.');
|
||||
}
|
||||
if (actualScore != null) {
|
||||
_nonBlank(scoreLabelSnapshot, 'Step score label snapshot');
|
||||
_nonBlank(scoreUnitSnapshot, 'Step score unit snapshot');
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ part 'app_database.g.dart';
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
ActiveExerciseStepProgressStates,
|
||||
ActiveExerciseStepResults,
|
||||
ActiveRestStates,
|
||||
ActiveScoreStopwatchStates,
|
||||
ActiveSetResults,
|
||||
@ -20,6 +22,7 @@ part 'app_database.g.dart';
|
||||
Programs,
|
||||
WorkoutHistories,
|
||||
WorkoutHistorySetResults,
|
||||
WorkoutHistoryStepResults,
|
||||
WorkoutTemplateExerciseOverrides,
|
||||
WorkoutTemplatePrograms,
|
||||
WorkoutTemplates,
|
||||
@ -38,7 +41,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 8;
|
||||
int get schemaVersion => 9;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -77,6 +80,9 @@ final class AppDatabase extends _$AppDatabase {
|
||||
if (from < 8) {
|
||||
await _migrateToSchema8(migrator);
|
||||
}
|
||||
if (from < 9) {
|
||||
await _migrateToSchema9(migrator);
|
||||
}
|
||||
await _createIndexes();
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
@ -133,6 +139,15 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id '
|
||||
'ON active_rest_states (active_workout_session_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS '
|
||||
'idx_active_exercise_step_progress_states_session_id '
|
||||
'ON active_exercise_step_progress_states (active_workout_session_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_exercise_step_results_session_id '
|
||||
'ON active_exercise_step_results (active_workout_session_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
||||
'idx_active_workout_sessions_single_open '
|
||||
@ -148,6 +163,10 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
|
||||
'ON workout_history_set_results (workout_history_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id '
|
||||
'ON workout_history_step_results (workout_history_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_change_log_entity '
|
||||
'ON change_log (entity_type, entity_id)',
|
||||
@ -164,6 +183,8 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
const _syncableTableNames = [
|
||||
'active_exercise_step_progress_states',
|
||||
'active_exercise_step_results',
|
||||
'active_rest_states',
|
||||
'active_score_stopwatch_states',
|
||||
'active_set_results',
|
||||
@ -176,6 +197,7 @@ const _syncableTableNames = [
|
||||
'programs',
|
||||
'workout_history',
|
||||
'workout_history_set_results',
|
||||
'workout_history_step_results',
|
||||
'workout_template_exercise_overrides',
|
||||
'workout_template_programs',
|
||||
'workout_templates',
|
||||
@ -287,4 +309,10 @@ extension on AppDatabase {
|
||||
'exercise_steps_snapshot_json TEXT',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema9(Migrator migrator) async {
|
||||
await migrator.createTable(activeExerciseStepProgressStates);
|
||||
await migrator.createTable(activeExerciseStepResults);
|
||||
await migrator.createTable(workoutHistoryStepResults);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -532,6 +532,38 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExerciseStepProgressState(
|
||||
domain.ActiveExerciseStepProgressState state,
|
||||
) async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'active_exercise_step_progress_states',
|
||||
entityType: 'ActiveExerciseStepProgressState',
|
||||
metadata: state.metadata,
|
||||
write: () => database
|
||||
.into(database.activeExerciseStepProgressStates)
|
||||
.insertOnConflictUpdate(
|
||||
_activeExerciseStepProgressStateCompanion(state),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExerciseStepResult(
|
||||
domain.ActiveExerciseStepResult result,
|
||||
) async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'active_exercise_step_results',
|
||||
entityType: 'ActiveExerciseStepResult',
|
||||
metadata: result.metadata,
|
||||
write: () => database
|
||||
.into(database.activeExerciseStepResults)
|
||||
.insertOnConflictUpdate(_activeExerciseStepResultCompanion(result)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteScoreStopwatchState({
|
||||
required String sessionId,
|
||||
@ -588,6 +620,28 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveExerciseStepProgressState?>
|
||||
findExerciseStepProgressState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final row =
|
||||
await (database.select(database.activeExerciseStepProgressStates)
|
||||
..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.programIndex.equals(programIndex) &
|
||||
table.exerciseIndex.equals(exerciseIndex) &
|
||||
table.setIndex.equals(setIndex) &
|
||||
table.deletedAt.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _activeExerciseStepProgressStateFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
final rows =
|
||||
@ -602,6 +656,47 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
return rows.map(_activeSetResultFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveExerciseStepProgressState>>
|
||||
listExerciseStepProgressStates(String sessionId) async {
|
||||
final rows =
|
||||
await (database.select(database.activeExerciseStepProgressStates)
|
||||
..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([
|
||||
(table) => OrderingTerm.asc(table.programIndex),
|
||||
(table) => OrderingTerm.asc(table.exerciseIndex),
|
||||
(table) => OrderingTerm.asc(table.setIndex),
|
||||
]))
|
||||
.get();
|
||||
return rows.map(_activeExerciseStepProgressStateFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveExerciseStepResult>> listExerciseStepResults(
|
||||
String sessionId,
|
||||
) async {
|
||||
final rows =
|
||||
await (database.select(database.activeExerciseStepResults)
|
||||
..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([
|
||||
(table) => OrderingTerm.asc(table.programIndex),
|
||||
(table) => OrderingTerm.asc(table.exerciseIndex),
|
||||
(table) => OrderingTerm.asc(table.setIndex),
|
||||
(table) => OrderingTerm.asc(table.passageIndex),
|
||||
(table) => OrderingTerm.asc(table.stepIndex),
|
||||
]))
|
||||
.get();
|
||||
return rows.map(_activeExerciseStepResultFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
|
||||
final rows =
|
||||
@ -661,9 +756,25 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
(table) => OrderingTerm.asc(table.setIndex),
|
||||
]))
|
||||
.get();
|
||||
final stepResults =
|
||||
await (database.select(database.workoutHistoryStepResults)
|
||||
..where(
|
||||
(table) =>
|
||||
table.workoutHistoryId.equals(id) &
|
||||
table.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([
|
||||
(table) => OrderingTerm.asc(table.programIndex),
|
||||
(table) => OrderingTerm.asc(table.exerciseIndex),
|
||||
(table) => OrderingTerm.asc(table.setIndex),
|
||||
(table) => OrderingTerm.asc(table.passageIndex),
|
||||
(table) => OrderingTerm.asc(table.stepIndex),
|
||||
]))
|
||||
.get();
|
||||
return _workoutHistoryFromRow(
|
||||
row,
|
||||
results.map(_workoutHistorySetResultFromRow).toList(),
|
||||
stepResults.map(_workoutHistoryStepResultFromRow).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -699,6 +810,9 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
for (final result in history.results) {
|
||||
await saveSetResult(result);
|
||||
}
|
||||
for (final result in history.stepResults) {
|
||||
await saveStepResult(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -715,6 +829,19 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveStepResult(domain.WorkoutHistoryStepResult result) async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'workout_history_step_results',
|
||||
entityType: 'WorkoutHistoryStepResult',
|
||||
metadata: result.metadata,
|
||||
write: () => database
|
||||
.into(database.workoutHistoryStepResults)
|
||||
.insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id, DateTime deletedAt) async {
|
||||
await database.transaction(() async {
|
||||
@ -753,6 +880,18 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
resultRows,
|
||||
deletedAt,
|
||||
);
|
||||
final stepResultRows =
|
||||
await (database.select(database.workoutHistoryStepResults)..where(
|
||||
(table) =>
|
||||
table.workoutHistoryId.equals(id) &
|
||||
table.deletedAt.isNull(),
|
||||
))
|
||||
.get();
|
||||
await _softDeleteWorkoutHistoryStepResultRows(
|
||||
database,
|
||||
stepResultRows,
|
||||
deletedAt,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1149,6 +1288,35 @@ Future<void> _softDeleteWorkoutHistorySetResultRows(
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _softDeleteWorkoutHistoryStepResultRows(
|
||||
db.AppDatabase database,
|
||||
List<db.WorkoutHistoryStepResult> rows,
|
||||
DateTime deletedAt,
|
||||
) async {
|
||||
for (final row in rows) {
|
||||
final revision = row.localRevision + 1;
|
||||
await (database.update(
|
||||
database.workoutHistoryStepResults,
|
||||
)..where((table) => table.id.equals(row.id))).write(
|
||||
db.WorkoutHistoryStepResultsCompanion(
|
||||
deletedAt: Value(deletedAt.toUtc()),
|
||||
updatedAt: Value(deletedAt.toUtc()),
|
||||
localRevision: Value(revision),
|
||||
syncState: const Value('deleted'),
|
||||
),
|
||||
);
|
||||
await _writeChangeLog(
|
||||
database: database,
|
||||
entityType: 'WorkoutHistoryStepResult',
|
||||
entityId: row.id,
|
||||
operation: 'softDelete',
|
||||
localRevision: revision,
|
||||
originDeviceId: row.originDeviceId,
|
||||
createdAt: deletedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _utc(DateTime value) => value.toUtc();
|
||||
|
||||
DateTime? _utcOrNull(DateTime? value) => value?.toUtc();
|
||||
@ -1732,6 +1900,142 @@ domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveExerciseStepProgressStatesCompanion
|
||||
_activeExerciseStepProgressStateCompanion(
|
||||
domain.ActiveExerciseStepProgressState state,
|
||||
) {
|
||||
final values = _metadataValues(state.metadata);
|
||||
return db.ActiveExerciseStepProgressStatesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
|
||||
programIndex: Value(state.programIndex),
|
||||
exerciseIndex: Value(state.exerciseIndex),
|
||||
setIndex: Value(state.setIndex),
|
||||
currentPassageIndex: Value(state.currentPassageIndex),
|
||||
currentStepIndex: Value(state.currentStepIndex),
|
||||
currentStepSnapshotId: Value(state.currentStepSnapshotId),
|
||||
status: Value(_exerciseStepProgressStatusToDb(state.status)),
|
||||
startedAt: Value<DateTime?>(_utcOrNull(state.startedAt)),
|
||||
accumulatedMs: Value(state.accumulatedMs),
|
||||
lastTransitionAt: Value(state.lastTransitionAt.toUtc()),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveExerciseStepProgressState
|
||||
_activeExerciseStepProgressStateFromRow(
|
||||
db.ActiveExerciseStepProgressState row,
|
||||
) {
|
||||
return domain.ActiveExerciseStepProgressState(
|
||||
metadata: _metadataFromRow(row),
|
||||
activeWorkoutSessionId: row.activeWorkoutSessionId,
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
currentPassageIndex: row.currentPassageIndex,
|
||||
currentStepIndex: row.currentStepIndex,
|
||||
currentStepSnapshotId: row.currentStepSnapshotId,
|
||||
status: _exerciseStepProgressStatusFromDb(row.status),
|
||||
startedAt: _utcOrNull(row.startedAt),
|
||||
accumulatedMs: row.accumulatedMs,
|
||||
lastTransitionAt: _utc(row.lastTransitionAt),
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveExerciseStepResultsCompanion _activeExerciseStepResultCompanion(
|
||||
domain.ActiveExerciseStepResult result,
|
||||
) {
|
||||
final values = _metadataValues(result.metadata);
|
||||
return db.ActiveExerciseStepResultsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
activeWorkoutSessionId: Value(result.activeWorkoutSessionId),
|
||||
programSnapshotId: Value(result.programSnapshotId),
|
||||
exerciseSnapshotId: Value(result.exerciseSnapshotId),
|
||||
programIndex: Value(result.programIndex),
|
||||
exerciseIndex: Value(result.exerciseIndex),
|
||||
setIndex: Value(result.setIndex),
|
||||
passageIndex: Value(result.passageIndex),
|
||||
stepIndex: Value(result.stepIndex),
|
||||
stepSnapshotId: Value(result.stepSnapshotId),
|
||||
stepNameSnapshot: Value(result.stepNameSnapshot),
|
||||
stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)),
|
||||
targetValueSnapshot: Value(result.targetValueSnapshot),
|
||||
hasScoreSnapshot: Value(result.hasScoreSnapshot),
|
||||
scoreInputModeSnapshot: Value<String?>(
|
||||
result.scoreInputModeSnapshot == null
|
||||
? null
|
||||
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
|
||||
),
|
||||
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
|
||||
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
|
||||
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
|
||||
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
|
||||
status: Value(_setResultStatusToDb(result.status)),
|
||||
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
|
||||
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
|
||||
actualTimeMs: Value<int?>(result.actualTimeMs),
|
||||
actualReps: Value<int?>(result.actualReps),
|
||||
actualScore: Value<double?>(result.actualScore),
|
||||
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
|
||||
note: Value<String?>(result.note),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveExerciseStepResult _activeExerciseStepResultFromRow(
|
||||
db.ActiveExerciseStepResult row,
|
||||
) {
|
||||
return domain.ActiveExerciseStepResult(
|
||||
metadata: _metadataFromRow(row),
|
||||
activeWorkoutSessionId: row.activeWorkoutSessionId,
|
||||
programSnapshotId: row.programSnapshotId,
|
||||
exerciseSnapshotId: row.exerciseSnapshotId,
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
passageIndex: row.passageIndex,
|
||||
stepIndex: row.stepIndex,
|
||||
stepSnapshotId: row.stepSnapshotId,
|
||||
stepNameSnapshot: row.stepNameSnapshot,
|
||||
stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot),
|
||||
targetValueSnapshot: row.targetValueSnapshot,
|
||||
hasScoreSnapshot: row.hasScoreSnapshot,
|
||||
scoreInputModeSnapshot: row.scoreInputModeSnapshot == null
|
||||
? null
|
||||
: _scoreInputModeFromDb(row.scoreInputModeSnapshot!),
|
||||
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
||||
targetScoreSnapshot: row.targetScoreSnapshot,
|
||||
targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot,
|
||||
status: _setResultStatusFromDb(row.status),
|
||||
startedAt: _utcOrNull(row.startedAt),
|
||||
completedAt: _utcOrNull(row.completedAt),
|
||||
actualTimeMs: row.actualTimeMs,
|
||||
actualReps: row.actualReps,
|
||||
actualScore: row.actualScore,
|
||||
actualScoreTimeMs: row.actualScoreTimeMs,
|
||||
note: row.note,
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
|
||||
domain.WorkoutHistory history,
|
||||
) {
|
||||
@ -1805,9 +2109,59 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutHistoryStepResultsCompanion _workoutHistoryStepResultCompanion(
|
||||
domain.WorkoutHistoryStepResult result,
|
||||
) {
|
||||
final values = _metadataValues(result.metadata);
|
||||
return db.WorkoutHistoryStepResultsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
workoutHistoryId: Value(result.workoutHistoryId),
|
||||
programSnapshotId: Value(result.programSnapshotId),
|
||||
exerciseSnapshotId: Value(result.exerciseSnapshotId),
|
||||
programIndex: Value(result.programIndex),
|
||||
exerciseIndex: Value(result.exerciseIndex),
|
||||
setIndex: Value(result.setIndex),
|
||||
passageIndex: Value(result.passageIndex),
|
||||
stepIndex: Value(result.stepIndex),
|
||||
stepSnapshotId: Value(result.stepSnapshotId),
|
||||
stepNameSnapshot: Value(result.stepNameSnapshot),
|
||||
stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)),
|
||||
targetValueSnapshot: Value(result.targetValueSnapshot),
|
||||
hasScoreSnapshot: Value(result.hasScoreSnapshot),
|
||||
scoreInputModeSnapshot: Value<String?>(
|
||||
result.scoreInputModeSnapshot == null
|
||||
? null
|
||||
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
|
||||
),
|
||||
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
|
||||
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
|
||||
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
|
||||
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
|
||||
status: Value(_setResultStatusToDb(result.status)),
|
||||
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
|
||||
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
|
||||
actualTimeMs: Value<int?>(result.actualTimeMs),
|
||||
actualReps: Value<int?>(result.actualReps),
|
||||
actualScore: Value<double?>(result.actualScore),
|
||||
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
|
||||
note: Value<String?>(result.note),
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutHistory _workoutHistoryFromRow(
|
||||
db.WorkoutHistory row,
|
||||
List<domain.WorkoutHistorySetResult> results,
|
||||
List<domain.WorkoutHistoryStepResult> stepResults,
|
||||
) {
|
||||
return domain.WorkoutHistory(
|
||||
metadata: _metadataFromRow(row),
|
||||
@ -1820,6 +2174,7 @@ domain.WorkoutHistory _workoutHistoryFromRow(
|
||||
completed: row.completed,
|
||||
historySnapshotJson: row.historySnapshotJson,
|
||||
results: results,
|
||||
stepResults: stepResults,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1856,6 +2211,42 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow(
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow(
|
||||
db.WorkoutHistoryStepResult row,
|
||||
) {
|
||||
return domain.WorkoutHistoryStepResult(
|
||||
metadata: _metadataFromRow(row),
|
||||
workoutHistoryId: row.workoutHistoryId,
|
||||
programSnapshotId: row.programSnapshotId,
|
||||
exerciseSnapshotId: row.exerciseSnapshotId,
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
passageIndex: row.passageIndex,
|
||||
stepIndex: row.stepIndex,
|
||||
stepSnapshotId: row.stepSnapshotId,
|
||||
stepNameSnapshot: row.stepNameSnapshot,
|
||||
stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot),
|
||||
targetValueSnapshot: row.targetValueSnapshot,
|
||||
hasScoreSnapshot: row.hasScoreSnapshot,
|
||||
scoreInputModeSnapshot: row.scoreInputModeSnapshot == null
|
||||
? null
|
||||
: _scoreInputModeFromDb(row.scoreInputModeSnapshot!),
|
||||
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
||||
targetScoreSnapshot: row.targetScoreSnapshot,
|
||||
targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot,
|
||||
status: _setResultStatusFromDb(row.status),
|
||||
startedAt: _utcOrNull(row.startedAt),
|
||||
completedAt: _utcOrNull(row.completedAt),
|
||||
actualTimeMs: row.actualTimeMs,
|
||||
actualReps: row.actualReps,
|
||||
actualScore: row.actualScore,
|
||||
actualScoreTimeMs: row.actualScoreTimeMs,
|
||||
note: row.note,
|
||||
);
|
||||
}
|
||||
|
||||
String _syncStateToDb(domain.SyncState state) => switch (state) {
|
||||
domain.SyncState.localOnly => 'localOnly',
|
||||
domain.SyncState.dirty => 'dirty',
|
||||
@ -1939,6 +2330,33 @@ domain.ActiveScoreStopwatchStatus _scoreStopwatchStatusFromDb(String value) =>
|
||||
),
|
||||
};
|
||||
|
||||
String _exerciseStepProgressStatusToDb(
|
||||
domain.ActiveExerciseStepProgressStatus status,
|
||||
) => switch (status) {
|
||||
domain.ActiveExerciseStepProgressStatus.notStarted => 'notStarted',
|
||||
domain.ActiveExerciseStepProgressStatus.waitingManual => 'waitingManual',
|
||||
domain.ActiveExerciseStepProgressStatus.runningTimer => 'runningTimer',
|
||||
domain.ActiveExerciseStepProgressStatus.pausedTimer => 'pausedTimer',
|
||||
domain.ActiveExerciseStepProgressStatus.stoppedTimer => 'stoppedTimer',
|
||||
domain.ActiveExerciseStepProgressStatus.sequenceComplete =>
|
||||
'sequenceComplete',
|
||||
};
|
||||
|
||||
domain.ActiveExerciseStepProgressStatus _exerciseStepProgressStatusFromDb(
|
||||
String value,
|
||||
) => switch (value) {
|
||||
'notStarted' => domain.ActiveExerciseStepProgressStatus.notStarted,
|
||||
'waitingManual' => domain.ActiveExerciseStepProgressStatus.waitingManual,
|
||||
'runningTimer' => domain.ActiveExerciseStepProgressStatus.runningTimer,
|
||||
'pausedTimer' => domain.ActiveExerciseStepProgressStatus.pausedTimer,
|
||||
'stoppedTimer' => domain.ActiveExerciseStepProgressStatus.stoppedTimer,
|
||||
'sequenceComplete' =>
|
||||
domain.ActiveExerciseStepProgressStatus.sequenceComplete,
|
||||
_ => throw domain.DomainException(
|
||||
'Unknown active exercise step progress status: $value',
|
||||
),
|
||||
};
|
||||
|
||||
String? _encodeImageMediaIdsSnapshot(List<String> imageMediaIds) {
|
||||
return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds);
|
||||
}
|
||||
|
||||
@ -419,6 +419,108 @@ class ActiveRestStates extends SyncableTable {
|
||||
];
|
||||
}
|
||||
|
||||
class ActiveExerciseStepProgressStates extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'active_exercise_step_progress_states';
|
||||
|
||||
TextColumn get activeWorkoutSessionId =>
|
||||
text().references(ActiveWorkoutSessions, #id)();
|
||||
IntColumn get programIndex => integer()();
|
||||
IntColumn get exerciseIndex => integer()();
|
||||
IntColumn get setIndex => integer()();
|
||||
IntColumn get currentPassageIndex => integer()();
|
||||
IntColumn get currentStepIndex => integer()();
|
||||
TextColumn get currentStepSnapshotId => text().withLength(min: 1)();
|
||||
TextColumn get status => text()();
|
||||
DateTimeColumn get startedAt => dateTime().nullable()();
|
||||
IntColumn get accumulatedMs => integer()();
|
||||
DateTimeColumn get lastTransitionAt => dateTime()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
|
||||
'set_index)',
|
||||
'CHECK (program_index >= 0)',
|
||||
'CHECK (exercise_index >= 0)',
|
||||
'CHECK (set_index >= 0)',
|
||||
'CHECK (current_passage_index >= 0)',
|
||||
'CHECK (current_step_index >= 0)',
|
||||
"CHECK (status IN ('notStarted', 'waitingManual', 'runningTimer', "
|
||||
"'pausedTimer', 'stoppedTimer', 'sequenceComplete'))",
|
||||
'CHECK (status != \'runningTimer\' OR started_at IS NOT NULL)',
|
||||
'CHECK (accumulated_ms >= 0)',
|
||||
];
|
||||
}
|
||||
|
||||
class ActiveExerciseStepResults extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'active_exercise_step_results';
|
||||
|
||||
TextColumn get activeWorkoutSessionId =>
|
||||
text().references(ActiveWorkoutSessions, #id)();
|
||||
TextColumn get programSnapshotId => text().withLength(min: 1)();
|
||||
TextColumn get exerciseSnapshotId => text().withLength(min: 1)();
|
||||
IntColumn get programIndex => integer()();
|
||||
IntColumn get exerciseIndex => integer()();
|
||||
IntColumn get setIndex => integer()();
|
||||
IntColumn get passageIndex => integer()();
|
||||
IntColumn get stepIndex => integer()();
|
||||
TextColumn get stepSnapshotId => text().withLength(min: 1)();
|
||||
TextColumn get stepNameSnapshot => text().withLength(min: 1)();
|
||||
TextColumn get stepTypeSnapshot => text()();
|
||||
IntColumn get targetValueSnapshot => integer()();
|
||||
BoolColumn get hasScoreSnapshot => boolean()();
|
||||
TextColumn get scoreInputModeSnapshot => text().nullable()();
|
||||
TextColumn get scoreLabelSnapshot => text().nullable()();
|
||||
TextColumn get scoreUnitSnapshot => text().nullable()();
|
||||
RealColumn get targetScoreSnapshot => real().nullable()();
|
||||
IntColumn get targetScoreTimeMsSnapshot => integer().nullable()();
|
||||
TextColumn get status => text()();
|
||||
DateTimeColumn get startedAt => dateTime().nullable()();
|
||||
DateTimeColumn get completedAt => dateTime().nullable()();
|
||||
IntColumn get actualTimeMs => integer().nullable()();
|
||||
IntColumn get actualReps => integer().nullable()();
|
||||
RealColumn get actualScore => real().nullable()();
|
||||
IntColumn get actualScoreTimeMs => integer().nullable()();
|
||||
TextColumn get note => text().nullable()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
|
||||
'set_index, passage_index, step_index)',
|
||||
'CHECK (program_index >= 0)',
|
||||
'CHECK (exercise_index >= 0)',
|
||||
'CHECK (set_index >= 0)',
|
||||
'CHECK (passage_index >= 0)',
|
||||
'CHECK (step_index >= 0)',
|
||||
"CHECK (step_type_snapshot IN ('time', 'reps'))",
|
||||
'CHECK (target_value_snapshot > 0)',
|
||||
"CHECK (score_input_mode_snapshot IS NULL OR "
|
||||
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
|
||||
"CHECK (status IN ('completed', 'skipped'))",
|
||||
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',
|
||||
'CHECK (actual_reps IS NULL OR actual_reps >= 0)',
|
||||
'CHECK (actual_score IS NULL OR actual_score >= 0)',
|
||||
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
|
||||
'CHECK (actual_time_ms IS NULL OR step_type_snapshot = \'time\')',
|
||||
'CHECK (actual_reps IS NULL OR step_type_snapshot = \'reps\')',
|
||||
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
|
||||
'AND actual_reps IS NULL AND actual_score IS NULL '
|
||||
'AND actual_score_time_ms IS NULL))',
|
||||
'CHECK (target_score_snapshot IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
|
||||
'CHECK (target_score_time_ms_snapshot IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
|
||||
'CHECK (actual_score IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
|
||||
'CHECK (actual_score_time_ms IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
|
||||
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
|
||||
'CHECK (target_score_snapshot IS NULL OR '
|
||||
'target_score_time_ms_snapshot IS NULL)',
|
||||
];
|
||||
}
|
||||
|
||||
class WorkoutHistories extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'workout_history';
|
||||
@ -511,6 +613,74 @@ class WorkoutHistorySetResults extends SyncableTable {
|
||||
];
|
||||
}
|
||||
|
||||
class WorkoutHistoryStepResults extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'workout_history_step_results';
|
||||
|
||||
TextColumn get workoutHistoryId => text().references(WorkoutHistories, #id)();
|
||||
TextColumn get programSnapshotId => text().withLength(min: 1)();
|
||||
TextColumn get exerciseSnapshotId => text().withLength(min: 1)();
|
||||
IntColumn get programIndex => integer()();
|
||||
IntColumn get exerciseIndex => integer()();
|
||||
IntColumn get setIndex => integer()();
|
||||
IntColumn get passageIndex => integer()();
|
||||
IntColumn get stepIndex => integer()();
|
||||
TextColumn get stepSnapshotId => text().withLength(min: 1)();
|
||||
TextColumn get stepNameSnapshot => text().withLength(min: 1)();
|
||||
TextColumn get stepTypeSnapshot => text()();
|
||||
IntColumn get targetValueSnapshot => integer()();
|
||||
BoolColumn get hasScoreSnapshot => boolean()();
|
||||
TextColumn get scoreInputModeSnapshot => text().nullable()();
|
||||
TextColumn get scoreLabelSnapshot => text().nullable()();
|
||||
TextColumn get scoreUnitSnapshot => text().nullable()();
|
||||
RealColumn get targetScoreSnapshot => real().nullable()();
|
||||
IntColumn get targetScoreTimeMsSnapshot => integer().nullable()();
|
||||
TextColumn get status => text()();
|
||||
DateTimeColumn get startedAt => dateTime().nullable()();
|
||||
DateTimeColumn get completedAt => dateTime().nullable()();
|
||||
IntColumn get actualTimeMs => integer().nullable()();
|
||||
IntColumn get actualReps => integer().nullable()();
|
||||
RealColumn get actualScore => real().nullable()();
|
||||
IntColumn get actualScoreTimeMs => integer().nullable()();
|
||||
TextColumn get note => text().nullable()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'UNIQUE (workout_history_id, program_index, exercise_index, set_index, '
|
||||
'passage_index, step_index)',
|
||||
'CHECK (program_index >= 0)',
|
||||
'CHECK (exercise_index >= 0)',
|
||||
'CHECK (set_index >= 0)',
|
||||
'CHECK (passage_index >= 0)',
|
||||
'CHECK (step_index >= 0)',
|
||||
"CHECK (step_type_snapshot IN ('time', 'reps'))",
|
||||
'CHECK (target_value_snapshot > 0)',
|
||||
"CHECK (score_input_mode_snapshot IS NULL OR "
|
||||
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
|
||||
"CHECK (status IN ('completed', 'skipped'))",
|
||||
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',
|
||||
'CHECK (actual_reps IS NULL OR actual_reps >= 0)',
|
||||
'CHECK (actual_score IS NULL OR actual_score >= 0)',
|
||||
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
|
||||
'CHECK (actual_time_ms IS NULL OR step_type_snapshot = \'time\')',
|
||||
'CHECK (actual_reps IS NULL OR step_type_snapshot = \'reps\')',
|
||||
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
|
||||
'AND actual_reps IS NULL AND actual_score IS NULL '
|
||||
'AND actual_score_time_ms IS NULL))',
|
||||
'CHECK (target_score_snapshot IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
|
||||
'CHECK (target_score_time_ms_snapshot IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
|
||||
'CHECK (actual_score IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
|
||||
'CHECK (actual_score_time_ms IS NULL OR '
|
||||
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
|
||||
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
|
||||
'CHECK (target_score_snapshot IS NULL OR '
|
||||
'target_score_time_ms_snapshot IS NULL)',
|
||||
];
|
||||
}
|
||||
|
||||
class ChangeLogEntries extends Table {
|
||||
@override
|
||||
String get tableName => 'change_log';
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
@ -400,7 +402,11 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
var _saving = false;
|
||||
var _importingImage = false;
|
||||
var _importingVideo = false;
|
||||
var _stepsEnabled = false;
|
||||
var _nextStepDraftId = 0;
|
||||
final _stepDrafts = <_ExerciseStepDraft>[];
|
||||
String? _measureError;
|
||||
String? _stepError;
|
||||
String? _warning;
|
||||
|
||||
bool get _isEditing => widget.exercise != null;
|
||||
@ -434,6 +440,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
_hasReps = exercise?.hasRepsMeasure ?? false;
|
||||
_hasScore = exercise?.hasScoreMeasure ?? false;
|
||||
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
|
||||
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
|
||||
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
|
||||
_stepDrafts.add(_draftFromStep(step));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -446,6 +456,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
_defaultRepsController.dispose();
|
||||
_defaultScoreController.dispose();
|
||||
_defaultScoreTimeController.dispose();
|
||||
for (final draft in _stepDrafts) {
|
||||
draft.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -666,6 +679,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
_buildExerciseStepsSection(context),
|
||||
if (_warning != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
MaterialBanner(
|
||||
@ -696,6 +711,305 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExerciseStepsSection(BuildContext context) {
|
||||
final reachedLimit = _stepDrafts.length >= 8;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Séquence d’étapes',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Rythmer cet exercice avec des étapes'),
|
||||
subtitle: Text(
|
||||
_stepsEnabled
|
||||
? 'Chaque passage suit ces étapes dans 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) {
|
||||
setState(() {
|
||||
update();
|
||||
@ -704,6 +1018,141 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _addStep() {
|
||||
if (_stepDrafts.length >= 8) {
|
||||
setState(() {
|
||||
_stepError = 'Un exercice peut contenir jusqu’à 8 étapes.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_stepError = null;
|
||||
_stepDrafts.add(
|
||||
_ExerciseStepDraft(
|
||||
id: 'step-draft-${_nextStepDraftId++}',
|
||||
name: 'Nouvelle étape',
|
||||
defaultTargetValue: '10',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _reorderStep(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final draft = _stepDrafts.removeAt(oldIndex);
|
||||
_stepDrafts.insert(newIndex, draft);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleStepAction(int index, _StepAction action) {
|
||||
switch (action) {
|
||||
case _StepAction.moveUp:
|
||||
if (index <= 0) return;
|
||||
setState(() {
|
||||
final draft = _stepDrafts.removeAt(index);
|
||||
_stepDrafts.insert(index - 1, draft);
|
||||
});
|
||||
break;
|
||||
case _StepAction.moveDown:
|
||||
if (index >= _stepDrafts.length - 1) return;
|
||||
setState(() {
|
||||
final draft = _stepDrafts.removeAt(index);
|
||||
_stepDrafts.insert(index + 1, draft);
|
||||
});
|
||||
break;
|
||||
case _StepAction.duplicate:
|
||||
_duplicateStep(index);
|
||||
break;
|
||||
case _StepAction.delete:
|
||||
unawaited(_confirmRemoveStep(index));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _duplicateStep(int index) {
|
||||
if (_stepDrafts.length >= 8) {
|
||||
setState(() {
|
||||
_stepError = 'Un exercice peut contenir jusqu’à 8 étapes.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_stepError = null;
|
||||
_stepDrafts.insert(index + 1, _stepDrafts[index].duplicate(
|
||||
'step-draft-${_nextStepDraftId++}',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveStep(int index) async {
|
||||
final draft = _stepDrafts[index];
|
||||
var confirmed = true;
|
||||
if (draft.hasFilledContent) {
|
||||
confirmed =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer cette étape ?'),
|
||||
content: const Text('Cette étape sera retirée de la séquence.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
if (!confirmed || !mounted) return;
|
||||
setState(() {
|
||||
final removed = _stepDrafts.removeAt(index);
|
||||
removed.dispose();
|
||||
_stepError = null;
|
||||
});
|
||||
}
|
||||
|
||||
_ExerciseStepDraft _draftFromStep(ExerciseStep step) {
|
||||
return _ExerciseStepDraft(
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
type: step.type,
|
||||
defaultTargetValue: step.defaultTargetValue.toString(),
|
||||
hasScore: step.hasScore,
|
||||
scoreInputMode: step.scoreInputMode,
|
||||
scoreLabel: step.scoreLabel,
|
||||
scoreUnit: step.scoreUnit,
|
||||
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalDoubleText(
|
||||
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
|
||||
)
|
||||
: _optionalDoubleText(step.defaultTargetScore),
|
||||
);
|
||||
}
|
||||
|
||||
List<ExerciseStep> _buildSteps() {
|
||||
if (!_stepsEnabled) return const [];
|
||||
return [
|
||||
for (var index = 0; index < _stepDrafts.length; index++)
|
||||
_stepDrafts[index].toExerciseStep(
|
||||
position: index,
|
||||
defaultTargetScoreTimeMs: _stepDrafts[index].scoreInputMode ==
|
||||
ScoreInputMode.stopwatch
|
||||
? _optionalSecondsToMilliseconds(
|
||||
_stepDrafts[index].scoreTargetController.text,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _importImage() async {
|
||||
if (_imageMediaIds.length >= 5) {
|
||||
_showSnackBar('Maximum 5 images par exercice.');
|
||||
@ -784,12 +1233,16 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
|
||||
Future<void> _save() async {
|
||||
final hasMeasure = _hasTime || _hasReps || _hasScore;
|
||||
final hasValidSteps = !_stepsEnabled || _stepDrafts.isNotEmpty;
|
||||
setState(() {
|
||||
_measureError = hasMeasure
|
||||
? null
|
||||
: 'Active au moins une mesure pour enregistrer.';
|
||||
_stepError = hasValidSteps
|
||||
? null
|
||||
: 'Ajoute au moins une étape ou désactive la séquence.';
|
||||
});
|
||||
if (!_formKey.currentState!.validate() || !hasMeasure) {
|
||||
if (!_formKey.currentState!.validate() || !hasMeasure || !hasValidSteps) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -832,6 +1285,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
|
||||
: null;
|
||||
final steps = _buildSteps();
|
||||
try {
|
||||
if (exercise == null) {
|
||||
await widget.exerciseUseCases.create(
|
||||
@ -850,6 +1304,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
defaultTargetReps: defaultTargetReps,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
steps: steps,
|
||||
);
|
||||
} else {
|
||||
await widget.exerciseUseCases.update(
|
||||
@ -869,6 +1324,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
defaultTargetReps: defaultTargetReps,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
steps: steps,
|
||||
);
|
||||
}
|
||||
if (mounted) {
|
||||
@ -961,6 +1417,104 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _StepAction { moveUp, moveDown, duplicate, delete }
|
||||
|
||||
final class _ExerciseStepDraft {
|
||||
_ExerciseStepDraft({
|
||||
required this.id,
|
||||
required String name,
|
||||
this.type = ExerciseStepType.time,
|
||||
required String defaultTargetValue,
|
||||
this.hasScore = false,
|
||||
this.scoreInputMode = ScoreInputMode.manual,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
String? scoreTarget,
|
||||
}) : nameController = TextEditingController(text: name),
|
||||
targetController = TextEditingController(text: defaultTargetValue),
|
||||
scoreLabelController = TextEditingController(text: scoreLabel),
|
||||
scoreUnitController = TextEditingController(text: scoreUnit),
|
||||
scoreTargetController = TextEditingController(text: scoreTarget);
|
||||
|
||||
final String id;
|
||||
ExerciseStepType type;
|
||||
bool hasScore;
|
||||
ScoreInputMode scoreInputMode;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController targetController;
|
||||
final TextEditingController scoreLabelController;
|
||||
final TextEditingController scoreUnitController;
|
||||
final TextEditingController scoreTargetController;
|
||||
|
||||
String get displayName {
|
||||
final text = nameController.text.trim();
|
||||
return text.isEmpty ? 'Étape sans nom' : text;
|
||||
}
|
||||
|
||||
bool get hasFilledContent {
|
||||
return nameController.text.trim().isNotEmpty ||
|
||||
targetController.text.trim().isNotEmpty ||
|
||||
hasScore ||
|
||||
scoreLabelController.text.trim().isNotEmpty ||
|
||||
scoreUnitController.text.trim().isNotEmpty ||
|
||||
scoreTargetController.text.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
_ExerciseStepDraft duplicate(String id) {
|
||||
return _ExerciseStepDraft(
|
||||
id: id,
|
||||
name: nameController.text,
|
||||
type: type,
|
||||
defaultTargetValue: targetController.text,
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: scoreInputMode,
|
||||
scoreLabel: scoreLabelController.text,
|
||||
scoreUnit: scoreUnitController.text,
|
||||
scoreTarget: scoreTargetController.text,
|
||||
);
|
||||
}
|
||||
|
||||
ExerciseStep toExerciseStep({
|
||||
required int position,
|
||||
required int? defaultTargetScoreTimeMs,
|
||||
}) {
|
||||
final scoreTargetText = scoreTargetController.text.trim();
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
position: position,
|
||||
name: nameController.text.trim(),
|
||||
type: type,
|
||||
defaultTargetValue: int.parse(targetController.text.trim()),
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
|
||||
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? scoreLabelController.text.trim()
|
||||
: null,
|
||||
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? scoreUnitController.text.trim()
|
||||
: null,
|
||||
defaultTargetScore:
|
||||
hasScore &&
|
||||
scoreInputMode == ScoreInputMode.manual &&
|
||||
scoreTargetText.isNotEmpty
|
||||
? double.parse(scoreTargetText)
|
||||
: null,
|
||||
defaultTargetScoreTimeMs:
|
||||
hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||||
? defaultTargetScoreTimeMs
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
targetController.dispose();
|
||||
scoreLabelController.dispose();
|
||||
scoreUnitController.dispose();
|
||||
scoreTargetController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final class MeasureBadge extends StatelessWidget {
|
||||
const MeasureBadge({
|
||||
required this.measure,
|
||||
|
||||
Reference in New Issue
Block a user