merge(main): fondation domain du score chronométré (ticket #25)

Fusionne feature/#25-score-chrono-domain — build_runner OK, analyze
propre, 39/39 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 01:10:22 +02:00
24 changed files with 3757 additions and 41 deletions

View File

@ -1,6 +1,6 @@
---
issueRef: "#25"
version: 1
version: 2
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784328239814
updatedAt: 1784329799370
---

View File

@ -2,7 +2,7 @@
id: "da78f1df-ff9b-4745-b108-b312eec9efe7"
number: 25
title: "[DevBackend] Domain et migration pour le score chronométré"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#18","kind":"relatesTo"}]
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784328239814
updatedAt: 1784328239814
version: 1
updatedAt: 1784329799370
version: 2
---
Fondation du ticket #18 (score chronométré). Ajouter ScoreInputMode (manual|stopwatch) sur Exercise avec propagation snapshot pattern (ProgramExercise, snapshot de séance, ActiveSetResult, WorkoutHistorySetResult). Ajouter actualScoreTimeMs/targetScoreTimeMs/targetScoreTimeMsOverride distincts de actualScore/targetScore/targetScoreOverride. Nouvelle table Drift ActiveScoreStopwatchState (status running|stopped, startedAt, accumulatedMs, stoppedAt, clé programIndex/exerciseIndex/setIndex). Migration Drift schemaVersion 2 -> 3. Use cases : démarrer/arrêter/reprendre/réinitialiser le chrono d'une série, copie de la durée finale vers ActiveSetResult à la validation. Cf. mémoires "gametime-ux-score-chrono" et "gametime-architecture-score-chrono" pour le détail complet.

View File

@ -1,8 +1,8 @@
---
issueRef: "#31"
version: 6
updatedBy: {"kind":"user"}
updatedAt: 1784329025027
version: 7
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784329139605
---
Décision UX : la flèche de retour doit ouvrir le même état Pause que le bouton "Pause" actuel (pas de "Quitter et sauvegarder" direct, pas de désactivation).

View File

@ -2,15 +2,15 @@
id: "2f55c804-65e7-465a-9296-f66dd6db684a"
number: 31
title: "[Bug] Fleche retour non fonctionnelle dans une seance en cours"
status: "inProgress"
status: "closed"
priority: "high"
sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: []
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784328786412
updatedAt: 1784329025027
version: 6
updatedAt: 1784329139605
version: 7
---
Je suis dans une séance, et le bouton de retour en haut à gauche ne fonctionne pas, je dois faire pause et abandonner ou quitter et sauvegarder pour sortir. Demander à UX l'action que doit produire cette fleche de retour

View File

@ -1,6 +1,6 @@
---
issueRef: "#33"
version: 1
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784329082771
updatedAt: 1784329182115
---

View File

@ -3,13 +3,14 @@ id: "a588ff04-9e55-4492-ac76-5298ba94e5c7"
number: 33
title: "[Bug] Impossible de lancer une seance"
status: "open"
priority: "critical"
sprint: null
priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: []
agentRefs: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784329082771
updatedAt: 1784329082771
version: 1
updatedAt: 1784329182115
version: 4
---
Si une seance est déjà en cours je ne peux poas en lancer une autre. Je trouve ça bien, j'aimerais juste qu'une petite popup me le dise car on a actuellement aucun retour pour ça

View File

@ -0,0 +1,6 @@
---
issueRef: "#34"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784329283319
---

View File

@ -0,0 +1,16 @@
---
id: "a78c5f84-3d2e-40ae-b800-880de78f2035"
number: 34
title: "Rendr ele compteur de série plus visible"
status: "open"
priority: "high"
sprint: null
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784329212434
updatedAt: 1784329283319
version: 3
---
Il faudrait que le compteur de série soit plus visible. Actuellement il est perdu avec le compteur de programme et le compteur d'exercice. J'aimerais qu'il soit plus visible, pour ça il faudrait le déplacer plus proche de notre série en cours et pourquoi pas le mettre plus gros/d'une autre couleur ? Laisser UX gérer ça

View File

@ -0,0 +1,6 @@
---
issueRef: "#35"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784329588125
---

View File

@ -0,0 +1,16 @@
---
id: "3f1a583a-320b-43c3-9993-52d249c9c430"
number: 35
title: "Proposer de emttre plsuieurs images pour un exercice"
status: "open"
priority: "low"
sprint: null
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784329532159
updatedAt: 1784329588125
version: 3
---
J'iamerais que lors de la création d'un exercice, ils oit possible d'associer jusqu'a 5 images

View File

@ -0,0 +1,6 @@
---
issueRef: "#36"
version: 6
updatedBy: {"kind":"user"}
updatedAt: 1784329810425
---

View File

@ -0,0 +1,16 @@
---
id: "1d0445c0-c8f7-4527-b948-42d894a9e265"
number: 36
title: "Afficher les images et les vidéos"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#35","kind":"dependsOn"}]
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784329666884
updatedAt: 1784329810425
version: 6
---
Pour chaque exercice on a la possibilité d'upload des images et une vidéo. J'iamerais que ces images et cette vidéo, s'il y en a, soit affichés pendant l'exercie, ou au moins qu'on ai la possibilité de les voir. Ces images et cette vidéo peuvent être dans le but de présenter et d'expliquer l'exercice, ils sont donc importants. Laisser UX décider de la façon de les afficher pour que ça ne surchage pas non plus l'interface et que ça reste dans la DA

View File

@ -1,3 +1,3 @@
{
"nextNumber": 34
"nextNumber": 37
}

View File

@ -279,13 +279,13 @@
"issueRef": "#25",
"path": "25",
"title": "[DevBackend] Domain et migration pour le score chronométré",
"status": "open",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"10ee045b-1c41-479e-ba03-dceed9edd495"
],
"updatedAt": 1784328239814
"updatedAt": 1784329799370
},
{
"issueRef": "#26",
@ -347,13 +347,13 @@
"issueRef": "#31",
"path": "31",
"title": "[Bug] Fleche retour non fonctionnelle dans une seance en cours",
"status": "inProgress",
"status": "closed",
"priority": "high",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784329025027
"updatedAt": 1784329139605
},
{
"issueRef": "#32",
@ -372,10 +372,46 @@
"path": "33",
"title": "[Bug] Impossible de lancer une seance",
"status": "open",
"priority": "critical",
"sprint": null,
"priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [],
"updatedAt": 1784329082771
"updatedAt": 1784329182115
},
{
"issueRef": "#34",
"path": "34",
"title": "Rendr ele compteur de série plus visible",
"status": "open",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784329283319
},
{
"issueRef": "#35",
"path": "35",
"title": "Proposer de emttre plsuieurs images pour un exercice",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784329588125
},
{
"issueRef": "#36",
"path": "36",
"title": "Afficher les images et les vidéos",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784329810425
}
]
}

View File

@ -80,9 +80,26 @@ abstract interface class ActiveSessionRepository {
Future<void> save(ActiveWorkoutSession session);
Future<void> saveSetResult(ActiveSetResult result);
Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
});
Future<ActiveRestState?> findRestStateById(String id);
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<List<ActiveSetResult>> listSetResults(String sessionId);
Future<List<ActiveRestState>> listRestStates(String sessionId);
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
);
}
abstract interface class WorkoutHistoryRepository {

View File

@ -24,6 +24,7 @@ final class ExerciseUseCases {
required bool hasTimeMeasure,
required bool hasRepsMeasure,
required bool hasScoreMeasure,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
}) async {
@ -37,6 +38,7 @@ final class ExerciseUseCases {
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
);
@ -61,6 +63,7 @@ final class ExerciseUseCases {
required bool hasTimeMeasure,
required bool hasRepsMeasure,
required bool hasScoreMeasure,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
}) async {
@ -77,6 +80,7 @@ final class ExerciseUseCases {
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
);
@ -285,6 +289,7 @@ final class ProgramUseCases {
availableTimeSnapshot: input.availableTimeSnapshot,
availableRepsSnapshot: input.availableRepsSnapshot,
availableScoreSnapshot: input.availableScoreSnapshot,
scoreInputModeSnapshot: input.scoreInputModeSnapshot,
scoreLabelSnapshot: input.scoreLabelSnapshot,
scoreUnitSnapshot: input.scoreUnitSnapshot,
setsCount: input.setsCount,
@ -294,6 +299,7 @@ final class ProgramUseCases {
targetTimeSeconds: input.targetTimeSeconds,
targetReps: input.targetReps,
targetScore: input.targetScore,
targetScoreTimeMs: input.targetScoreTimeMs,
restSecondsOverride: input.restSecondsOverride,
),
);
@ -317,6 +323,7 @@ final class ProgramUseCases {
int? targetTimeSeconds,
int? targetReps,
double? targetScore,
int? targetScoreTimeMs,
int? restSecondsOverride,
}) async {
final source = await exerciseRepository.findById(exerciseId);
@ -334,6 +341,7 @@ final class ProgramUseCases {
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
restSecondsOverride: restSecondsOverride,
);
await programRepository.saveExercise(programExercise);
@ -353,6 +361,7 @@ final class ProgramExerciseConfig {
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
required this.availableScoreSnapshot,
this.scoreInputModeSnapshot = ScoreInputMode.manual,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
@ -360,6 +369,7 @@ final class ProgramExerciseConfig {
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
this.restSecondsOverride,
});
@ -373,6 +383,7 @@ final class ProgramExerciseConfig {
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
final bool availableScoreSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final int setsCount;
@ -380,6 +391,7 @@ final class ProgramExerciseConfig {
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final int? restSecondsOverride;
}
@ -480,6 +492,7 @@ final class WorkoutTemplateUseCases {
targetTimeSecondsOverride: input.targetTimeSecondsOverride,
targetRepsOverride: input.targetRepsOverride,
targetScoreOverride: input.targetScoreOverride,
targetScoreTimeMsOverride: input.targetScoreTimeMsOverride,
),
);
}
@ -521,6 +534,7 @@ final class WorkoutTemplateUseCases {
int? targetTimeSecondsOverride,
int? targetRepsOverride,
double? targetScoreOverride,
int? targetScoreTimeMsOverride,
Set<WorkoutMeasure>? attemptedMeasureOverride,
}) async {
if (attemptedMeasureOverride != null) {
@ -537,6 +551,7 @@ final class WorkoutTemplateUseCases {
targetTimeSecondsOverride: targetTimeSecondsOverride,
targetRepsOverride: targetRepsOverride,
targetScoreOverride: targetScoreOverride,
targetScoreTimeMsOverride: targetScoreTimeMsOverride,
);
await templateRepository.saveOverride(override);
return override;
@ -570,6 +585,7 @@ final class WorkoutTemplateExerciseOverrideConfig {
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
this.targetScoreTimeMsOverride,
});
final EntityMetadata? existingMetadata;
@ -579,6 +595,7 @@ final class WorkoutTemplateExerciseOverrideConfig {
final int? targetTimeSecondsOverride;
final int? targetRepsOverride;
final double? targetScoreOverride;
final int? targetScoreTimeMsOverride;
}
final class ActiveWorkoutSessionUseCases {
@ -635,6 +652,7 @@ final class ActiveWorkoutSessionUseCases {
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
'targetRepsOverride': override.targetRepsOverride,
'targetScoreOverride': override.targetScoreOverride,
'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
},
)
.toList(),
@ -668,7 +686,21 @@ final class ActiveWorkoutSessionUseCases {
Future<ActiveWorkoutSession> pause(String sessionId) async {
final session = await _requiredSession(sessionId);
final paused = session.pause(clock.now());
final now = clock.now();
final runningStopwatches =
(await sessionRepository.listScoreStopwatchStates(
sessionId,
)).where((state) => state.status == ActiveScoreStopwatchStatus.running);
for (final state in runningStopwatches) {
final stopped = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: state.elapsedMillisecondsAt(now),
stoppedAt: now,
);
await sessionRepository.saveScoreStopwatchState(stopped);
}
final paused = session.pause(now);
await sessionRepository.save(paused);
return paused;
}
@ -699,10 +731,21 @@ final class ActiveWorkoutSessionUseCases {
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
ScoreInputMode scoreInputModeSnapshot = ScoreInputMode.manual,
String? scoreLabelSnapshot,
String? scoreUnitSnapshot,
}) async {
final now = clock.now();
final resolvedActualScoreTimeMs = await _resolveStopwatchScoreTimeMs(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
scoreInputMode: scoreInputModeSnapshot,
explicitActualScoreTimeMs: actualScoreTimeMs,
now: now,
);
final result = ActiveSetResult(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
@ -715,6 +758,8 @@ final class ActiveWorkoutSessionUseCases {
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: resolvedActualScoreTimeMs,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
status: SetResultStatus.completed,
@ -732,6 +777,8 @@ final class ActiveWorkoutSessionUseCases {
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
ScoreInputMode? scoreInputModeSnapshot,
String? scoreLabelSnapshot,
String? scoreUnitSnapshot,
String? note,
@ -766,6 +813,8 @@ final class ActiveWorkoutSessionUseCases {
}
final now = clock.now();
final isSkipped = status == SetResultStatus.skipped;
final resolvedScoreInputMode =
scoreInputModeSnapshot ?? snapshot.scoreInputModeSnapshot;
final upserted = ActiveSetResult(
metadata: existing == null
? _newMetadata(ids, originDeviceId, now)
@ -781,6 +830,8 @@ final class ActiveWorkoutSessionUseCases {
actualTimeMs: isSkipped ? null : actualTimeMs,
actualReps: isSkipped ? null : actualReps,
actualScore: isSkipped ? null : actualScore,
actualScoreTimeMs: isSkipped ? null : actualScoreTimeMs,
scoreInputModeSnapshot: resolvedScoreInputMode,
scoreLabelSnapshot: isSkipped
? null
: scoreLabelSnapshot ?? snapshot.scoreLabelSnapshot,
@ -794,6 +845,102 @@ final class ActiveWorkoutSessionUseCases {
return upserted;
}
Future<ActiveScoreStopwatchState> startScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
await _requiredSession(sessionId);
final now = clock.now();
final existing = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (existing != null) {
if (existing.status == ActiveScoreStopwatchStatus.running) {
return existing;
}
final resumed = existing.copyWith(
metadata: existing.metadata.touch(now),
status: ActiveScoreStopwatchStatus.running,
startedAt: now,
stoppedAt: null,
);
await sessionRepository.saveScoreStopwatchState(resumed);
return resumed;
}
final state = ActiveScoreStopwatchState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
status: ActiveScoreStopwatchStatus.running,
startedAt: now,
accumulatedMs: 0,
);
await sessionRepository.saveScoreStopwatchState(state);
return state;
}
Future<ActiveScoreStopwatchState> stopScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await _requiredStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state.status == ActiveScoreStopwatchStatus.stopped) {
return state;
}
final now = clock.now();
final stopped = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: state.elapsedMillisecondsAt(now),
stoppedAt: now,
);
await sessionRepository.saveScoreStopwatchState(stopped);
return stopped;
}
Future<ActiveScoreStopwatchState> resumeScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return startScoreStopwatch(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
Future<void> resetScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
await sessionRepository.deleteScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
deletedAt: clock.now(),
);
}
Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
final session = await _requiredSession(sessionId);
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
@ -960,6 +1107,58 @@ final class ActiveWorkoutSessionUseCases {
}
return session;
}
Future<ActiveScoreStopwatchState> _requiredStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
throw const DomainException('Active score stopwatch state not found.');
}
return state;
}
Future<int?> _resolveStopwatchScoreTimeMs({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required ScoreInputMode scoreInputMode,
required int? explicitActualScoreTimeMs,
required DateTime now,
}) async {
if (scoreInputMode != ScoreInputMode.stopwatch) {
return explicitActualScoreTimeMs;
}
final state = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
return explicitActualScoreTimeMs;
}
if (state.status == ActiveScoreStopwatchStatus.stopped) {
return explicitActualScoreTimeMs ?? state.accumulatedMs;
}
final stopped = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: state.elapsedMillisecondsAt(now),
stoppedAt: now,
);
await sessionRepository.saveScoreStopwatchState(stopped);
return explicitActualScoreTimeMs ?? stopped.accumulatedMs;
}
}
final class CloseWorkoutSessionUseCase {
@ -1025,6 +1224,8 @@ final class CloseWorkoutSessionUseCase {
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'status': result.status.name,
@ -1096,6 +1297,22 @@ void _validateOverrideTargets(
if (input.targetScoreOverride != null && exercise['scoreEnabled'] != true) {
throw const DomainException('Cannot override inactive score target.');
}
if (input.targetScoreTimeMsOverride != null &&
exercise['scoreEnabled'] != true) {
throw const DomainException('Cannot override inactive score target.');
}
final mode = _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
if (input.targetScoreOverride != null && mode != ScoreInputMode.manual) {
throw const DomainException(
'Cannot override manual score target on stopwatch score mode.',
);
}
if (input.targetScoreTimeMsOverride != null &&
mode != ScoreInputMode.stopwatch) {
throw const DomainException(
'Cannot override stopwatch score target on manual score mode.',
);
}
}
void _ensureEditablePastPosition({
@ -1142,6 +1359,13 @@ String _positionKey(int programIndex, int exerciseIndex, int setIndex) {
return '$programIndex:$exerciseIndex:$setIndex';
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
return switch (value) {
'stopwatch' => ScoreInputMode.stopwatch,
_ => ScoreInputMode.manual,
};
}
_SetPositionSnapshot? _findSetSnapshot({
required String resolvedTemplateSnapshotJson,
required int programIndex,
@ -1196,6 +1420,9 @@ List<_SetPositionSnapshot> _listSetSnapshots(
setIndex: setIndex,
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
),
);
}
@ -1213,6 +1440,7 @@ final class _SetPositionSnapshot {
required this.setIndex,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.scoreInputModeSnapshot,
});
final String programSnapshotId;
@ -1222,6 +1450,7 @@ final class _SetPositionSnapshot {
final int setIndex;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
}
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
@ -1250,13 +1479,18 @@ List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
timeEnabledSnapshot: snapshot?.timeEnabled ?? result.actualTimeMs != null,
repsEnabledSnapshot: snapshot?.repsEnabled ?? result.actualReps != null,
scoreEnabledSnapshot:
snapshot?.scoreEnabled ?? result.actualScore != null,
snapshot?.scoreEnabled ??
(result.actualScore != null || result.actualScoreTimeMs != null),
targetTimeSecondsSnapshot: snapshot?.targetTimeSeconds,
targetRepsSnapshot: snapshot?.targetReps,
targetScoreSnapshot: snapshot?.targetScore,
targetScoreTimeMsSnapshot: snapshot?.targetScoreTimeMs,
actualTimeMs: result.actualTimeMs,
actualReps: result.actualReps,
actualScore: result.actualScore,
actualScoreTimeMs: result.actualScoreTimeMs,
scoreInputModeSnapshot:
snapshot?.scoreInputModeSnapshot ?? result.scoreInputModeSnapshot,
scoreLabelSnapshot:
result.scoreLabelSnapshot ?? snapshot?.scoreLabelSnapshot,
scoreUnitSnapshot:
@ -1299,6 +1533,10 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
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?,
);
@ -1317,6 +1555,8 @@ final class _ResolvedExerciseSnapshot {
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
required this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
});
@ -1329,6 +1569,8 @@ final class _ResolvedExerciseSnapshot {
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
}

View File

@ -6,10 +6,14 @@ enum MediaKind { image, video }
enum WorkoutMeasure { time, reps, score }
enum ScoreInputMode { manual, stopwatch }
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
enum SetResultStatus { completed, skipped }
enum ActiveScoreStopwatchStatus { running, stopped }
final class DomainException implements Exception {
const DomainException(this.message);
@ -125,6 +129,7 @@ final class Exercise {
required this.hasTimeMeasure,
required this.hasRepsMeasure,
required this.hasScoreMeasure,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreLabel,
this.scoreUnit,
this.archivedAt,
@ -134,7 +139,12 @@ final class Exercise {
hasReps: hasRepsMeasure,
hasScore: hasScoreMeasure,
);
if (hasScoreMeasure) {
if (!hasScoreMeasure && scoreInputMode == ScoreInputMode.stopwatch) {
throw const DomainException(
'Stopwatch score mode requires an active score measure.',
);
}
if (hasScoreMeasure && scoreInputMode == ScoreInputMode.manual) {
_nonBlank(scoreLabel, 'Score label');
_nonBlank(scoreUnit, 'Score unit');
}
@ -148,6 +158,7 @@ final class Exercise {
final bool hasTimeMeasure;
final bool hasRepsMeasure;
final bool hasScoreMeasure;
final ScoreInputMode scoreInputMode;
final String? scoreLabel;
final String? scoreUnit;
final DateTime? archivedAt;
@ -171,6 +182,7 @@ final class Exercise {
bool? hasTimeMeasure,
bool? hasRepsMeasure,
bool? hasScoreMeasure,
ScoreInputMode? scoreInputMode,
Object? scoreLabel = _unchanged,
Object? scoreUnit = _unchanged,
Object? archivedAt = _unchanged,
@ -190,6 +202,7 @@ final class Exercise {
hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure ?? this.hasScoreMeasure,
scoreInputMode: scoreInputMode ?? this.scoreInputMode,
scoreLabel: scoreLabel == _unchanged
? this.scoreLabel
: scoreLabel as String?,
@ -249,6 +262,7 @@ final class ProgramExercise {
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
required this.availableScoreSnapshot,
this.scoreInputModeSnapshot = ScoreInputMode.manual,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
@ -258,6 +272,7 @@ final class ProgramExercise {
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
this.restSecondsOverride,
}) : exerciseNameSnapshot = _nonBlank(
exerciseNameSnapshot,
@ -282,7 +297,14 @@ final class ProgramExercise {
_requireNullablePositive(targetTimeSeconds, 'Target time seconds');
_requireNullablePositive(targetReps, 'Target reps');
_requireNullableNonNegativeDouble(targetScore, 'Target score');
_requireNullablePositive(targetScoreTimeMs, 'Target score time ms');
_requireNullableNonNegative(restSecondsOverride, 'Rest seconds override');
_requireScoreTargetShape(
scoreEnabled: scoreEnabled,
scoreInputMode: scoreInputModeSnapshot,
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
);
}
final EntityMetadata metadata;
@ -297,6 +319,7 @@ final class ProgramExercise {
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
final bool availableScoreSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final int setsCount;
@ -306,6 +329,7 @@ final class ProgramExercise {
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final int? restSecondsOverride;
static ProgramExercise snapshotFromExercise({
@ -318,6 +342,7 @@ final class ProgramExercise {
int? targetTimeSeconds,
int? targetReps,
double? targetScore,
int? targetScoreTimeMs,
int? restSecondsOverride,
}) {
final available = exercise.availableMeasures;
@ -339,6 +364,7 @@ final class ProgramExercise {
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
availableScoreSnapshot: exercise.hasScoreMeasure,
scoreInputModeSnapshot: exercise.scoreInputMode,
scoreLabelSnapshot: exercise.scoreLabel,
scoreUnitSnapshot: exercise.scoreUnit,
setsCount: setsCount,
@ -348,6 +374,7 @@ final class ProgramExercise {
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
restSecondsOverride: restSecondsOverride,
);
}
@ -364,6 +391,7 @@ final class ProgramExercise {
'availableTimeSnapshot': availableTimeSnapshot,
'availableRepsSnapshot': availableRepsSnapshot,
'availableScoreSnapshot': availableScoreSnapshot,
'scoreInputModeSnapshot': scoreInputModeSnapshot.name,
'scoreLabelSnapshot': scoreLabelSnapshot,
'scoreUnitSnapshot': scoreUnitSnapshot,
'setsCount': setsCount,
@ -373,6 +401,7 @@ final class ProgramExercise {
'targetTimeSeconds': targetTimeSeconds,
'targetReps': targetReps,
'targetScore': targetScore,
'targetScoreTimeMs': targetScoreTimeMs,
'restSecondsOverride': restSecondsOverride,
};
}
@ -452,6 +481,7 @@ final class WorkoutTemplateExerciseOverride {
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
this.targetScoreTimeMsOverride,
}) : snapshotProgramExerciseId = _nonBlank(
snapshotProgramExerciseId,
'Snapshot program exercise id',
@ -466,6 +496,15 @@ final class WorkoutTemplateExerciseOverride {
targetScoreOverride,
'Target score override',
);
_requireNullablePositive(
targetScoreTimeMsOverride,
'Target score time ms override',
);
if (targetScoreOverride != null && targetScoreTimeMsOverride != null) {
throw const DomainException(
'Manual and stopwatch score overrides cannot both be set.',
);
}
}
final EntityMetadata metadata;
@ -475,6 +514,7 @@ final class WorkoutTemplateExerciseOverride {
final int? targetTimeSecondsOverride;
final int? targetRepsOverride;
final double? targetScoreOverride;
final int? targetScoreTimeMsOverride;
}
final class ActiveWorkoutSession {
@ -587,15 +627,25 @@ final class ActiveSetResult {
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.actualScoreTimeMs,
this.scoreInputModeSnapshot = ScoreInputMode.manual,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.note,
this.status = SetResultStatus.completed,
}) {
if (status == SetResultStatus.skipped &&
(actualTimeMs != null || actualReps != null || actualScore != null)) {
(actualTimeMs != null ||
actualReps != null ||
actualScore != null ||
actualScoreTimeMs != null)) {
throw const DomainException('Skipped set results cannot have values.');
}
_requireScoreResultShape(
scoreInputMode: scoreInputModeSnapshot,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
}
final EntityMetadata metadata;
@ -610,12 +660,72 @@ final class ActiveSetResult {
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final int? actualScoreTimeMs;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final String? note;
final SetResultStatus status;
}
final class ActiveScoreStopwatchState {
ActiveScoreStopwatchState({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.status,
required this.startedAt,
required this.accumulatedMs,
this.stoppedAt,
}) {
_requireNonNegative(programIndex, 'Program index');
_requireNonNegative(exerciseIndex, 'Exercise index');
_requireNonNegative(setIndex, 'Set index');
_requireNonNegative(accumulatedMs, 'Accumulated milliseconds');
}
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final ActiveScoreStopwatchStatus status;
final DateTime startedAt;
final int accumulatedMs;
final DateTime? stoppedAt;
int elapsedMillisecondsAt(DateTime now) {
if (status == ActiveScoreStopwatchStatus.stopped) {
return accumulatedMs;
}
return accumulatedMs + now.difference(startedAt).inMilliseconds;
}
ActiveScoreStopwatchState copyWith({
EntityMetadata? metadata,
ActiveScoreStopwatchStatus? status,
DateTime? startedAt,
int? accumulatedMs,
Object? stoppedAt = _unchanged,
}) {
return ActiveScoreStopwatchState(
metadata: metadata ?? this.metadata,
activeWorkoutSessionId: activeWorkoutSessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
status: status ?? this.status,
startedAt: startedAt ?? this.startedAt,
accumulatedMs: accumulatedMs ?? this.accumulatedMs,
stoppedAt: stoppedAt == _unchanged
? this.stoppedAt
: stoppedAt as DateTime?,
);
}
}
final class ActiveRestState {
const ActiveRestState({
required this.metadata,
@ -707,9 +817,12 @@ final class WorkoutHistorySetResult {
this.targetTimeSecondsSnapshot,
this.targetRepsSnapshot,
this.targetScoreSnapshot,
this.targetScoreTimeMsSnapshot,
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.actualScoreTimeMs,
this.scoreInputModeSnapshot = ScoreInputMode.manual,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.startedAt,
@ -717,11 +830,25 @@ final class WorkoutHistorySetResult {
this.status = SetResultStatus.completed,
}) {
if (status == SetResultStatus.skipped &&
(actualTimeMs != null || actualReps != null || actualScore != null)) {
(actualTimeMs != null ||
actualReps != null ||
actualScore != null ||
actualScoreTimeMs != null)) {
throw const DomainException(
'Skipped history results cannot have values.',
);
}
_requireScoreTargetShape(
scoreEnabled: scoreEnabledSnapshot,
scoreInputMode: scoreInputModeSnapshot,
targetScore: targetScoreSnapshot,
targetScoreTimeMs: targetScoreTimeMsSnapshot,
);
_requireScoreResultShape(
scoreInputMode: scoreInputModeSnapshot,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
}
final EntityMetadata metadata;
@ -739,9 +866,12 @@ final class WorkoutHistorySetResult {
final int? targetTimeSecondsSnapshot;
final int? targetRepsSnapshot;
final double? targetScoreSnapshot;
final int? targetScoreTimeMsSnapshot;
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final int? actualScoreTimeMs;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final DateTime? startedAt;
@ -796,3 +926,50 @@ void _requireNullableNonNegativeDouble(double? value, String label) {
throw DomainException('$label must not be negative.');
}
}
void _requireScoreTargetShape({
required bool scoreEnabled,
required ScoreInputMode scoreInputMode,
required double? targetScore,
required int? targetScoreTimeMs,
}) {
if (targetScore != null && targetScoreTimeMs != null) {
throw const DomainException(
'Manual and stopwatch score targets cannot both be set.',
);
}
if (targetScore != null &&
(!scoreEnabled || scoreInputMode != ScoreInputMode.manual)) {
throw const DomainException(
'Manual score target requires manual score mode.',
);
}
if (targetScoreTimeMs != null &&
(!scoreEnabled || scoreInputMode != ScoreInputMode.stopwatch)) {
throw const DomainException(
'Stopwatch score target requires stopwatch score mode.',
);
}
}
void _requireScoreResultShape({
required ScoreInputMode scoreInputMode,
required double? actualScore,
required int? actualScoreTimeMs,
}) {
if (actualScore != null && actualScoreTimeMs != null) {
throw const DomainException(
'Manual and stopwatch score results cannot both be set.',
);
}
if (actualScore != null && scoreInputMode != ScoreInputMode.manual) {
throw const DomainException(
'Manual score result requires manual score mode.',
);
}
if (actualScoreTimeMs != null && scoreInputMode != ScoreInputMode.stopwatch) {
throw const DomainException(
'Stopwatch score result requires stopwatch score mode.',
);
}
}

View File

@ -8,6 +8,7 @@ part 'app_database.g.dart';
@DriftDatabase(
tables: [
ActiveRestStates,
ActiveScoreStopwatchStates,
ActiveSetResults,
ActiveWorkoutSessions,
ChangeLogEntries,
@ -35,7 +36,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration {
@ -56,6 +57,10 @@ final class AppDatabase extends _$AppDatabase {
"'skipped'))",
);
}
if (from < 3) {
await _migrateToSchema3(migrator);
}
await _createIndexes();
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
@ -95,6 +100,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
'ON active_set_results (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id '
'ON active_score_stopwatch_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id '
'ON active_rest_states (active_workout_session_id)',
@ -131,6 +140,7 @@ final class AppDatabase extends _$AppDatabase {
const _syncableTableNames = [
'active_rest_states',
'active_score_stopwatch_states',
'active_set_results',
'active_workout_sessions',
'exercises',
@ -143,3 +153,53 @@ const _syncableTableNames = [
'workout_template_programs',
'workout_templates',
];
extension on AppDatabase {
Future<void> _migrateToSchema3(Migrator migrator) async {
await customStatement(
'ALTER TABLE exercises ADD COLUMN score_input_mode TEXT NOT NULL '
"DEFAULT 'manual' CHECK (score_input_mode IN ('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN score_input_mode_snapshot TEXT '
"NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN "
"('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN target_score_time_ms INTEGER '
'CHECK (target_score_time_ms IS NULL OR target_score_time_ms > 0)',
);
await customStatement(
'ALTER TABLE workout_template_exercise_overrides ADD COLUMN '
'target_score_time_ms_override INTEGER CHECK '
'(target_score_time_ms_override IS NULL OR '
'target_score_time_ms_override > 0)',
);
await customStatement(
'ALTER TABLE active_set_results ADD COLUMN actual_score_time_ms INTEGER '
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
);
await customStatement(
'ALTER TABLE active_set_results ADD COLUMN score_input_mode_snapshot '
"TEXT NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN "
"('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE workout_history_set_results ADD COLUMN '
'score_input_mode_snapshot TEXT NOT NULL DEFAULT '
"'manual' CHECK (score_input_mode_snapshot IN ('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE workout_history_set_results ADD COLUMN '
'target_score_time_ms_snapshot INTEGER CHECK '
'(target_score_time_ms_snapshot IS NULL OR '
'target_score_time_ms_snapshot > 0)',
);
await customStatement(
'ALTER TABLE workout_history_set_results ADD COLUMN '
'actual_score_time_ms INTEGER CHECK (actual_score_time_ms IS NULL OR '
'actual_score_time_ms >= 0)',
);
await migrator.createTable(activeScoreStopwatchStates);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -469,6 +469,77 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return row == null ? null : _activeRestStateFromRow(row);
}
@override
Future<void> saveScoreStopwatchState(
domain.ActiveScoreStopwatchState state,
) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_score_stopwatch_states',
entityType: 'ActiveScoreStopwatchState',
metadata: state.metadata,
write: () => database
.into(database.activeScoreStopwatchStates)
.insertOnConflictUpdate(_activeScoreStopwatchStateCompanion(state)),
);
}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final row =
await (database.select(database.activeScoreStopwatchStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.delete(
database.activeScoreStopwatchStates,
)..where((table) => table.id.equals(row.id))).go();
await _writeChangeLog(
database: database,
entityType: 'ActiveScoreStopwatchState',
entityId: row.id,
operation: 'delete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
@override
Future<domain.ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final row =
await (database.select(database.activeScoreStopwatchStates)..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 : _activeScoreStopwatchStateFromRow(row);
}
@override
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
final rows =
@ -492,6 +563,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
.get();
return rows.map(_activeRestStateFromRow).toList();
}
@override
Future<List<domain.ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
final rows =
await (database.select(database.activeScoreStopwatchStates)
..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(_activeScoreStopwatchStateFromRow).toList();
}
}
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
@ -872,6 +963,7 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
hasTimeMeasure: Value(exercise.hasTimeMeasure),
hasRepsMeasure: Value(exercise.hasRepsMeasure),
hasScoreMeasure: Value(exercise.hasScoreMeasure),
scoreInputMode: Value(_scoreInputModeToDb(exercise.scoreInputMode)),
scoreLabel: Value(exercise.scoreLabel),
scoreUnit: Value(exercise.scoreUnit),
archivedAt: Value(_utcOrNull(exercise.archivedAt)),
@ -888,6 +980,7 @@ domain.Exercise _exerciseFromRow(db.Exercise row) {
hasTimeMeasure: row.hasTimeMeasure,
hasRepsMeasure: row.hasRepsMeasure,
hasScoreMeasure: row.hasScoreMeasure,
scoreInputMode: _scoreInputModeFromDb(row.scoreInputMode),
scoreLabel: row.scoreLabel,
scoreUnit: row.scoreUnit,
archivedAt: _utcOrNull(row.archivedAt),
@ -995,6 +1088,9 @@ db.ProgramExercisesCompanion _programExerciseCompanion(
availableTimeSnapshot: Value(exercise.availableTimeSnapshot),
availableRepsSnapshot: Value(exercise.availableRepsSnapshot),
availableScoreSnapshot: Value(exercise.availableScoreSnapshot),
scoreInputModeSnapshot: Value(
_scoreInputModeToDb(exercise.scoreInputModeSnapshot),
),
scoreLabelSnapshot: Value(exercise.scoreLabelSnapshot),
scoreUnitSnapshot: Value(exercise.scoreUnitSnapshot),
setsCount: Value(exercise.setsCount),
@ -1004,6 +1100,7 @@ db.ProgramExercisesCompanion _programExerciseCompanion(
targetTimeSeconds: Value(exercise.targetTimeSeconds),
targetReps: Value(exercise.targetReps),
targetScore: Value(exercise.targetScore),
targetScoreTimeMs: Value(exercise.targetScoreTimeMs),
restSecondsOverride: Value(exercise.restSecondsOverride),
);
}
@ -1022,6 +1119,7 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
availableTimeSnapshot: row.availableTimeSnapshot,
availableRepsSnapshot: row.availableRepsSnapshot,
availableScoreSnapshot: row.availableScoreSnapshot,
scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot),
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
setsCount: row.setsCount,
@ -1031,6 +1129,7 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
targetTimeSeconds: row.targetTimeSeconds,
targetReps: row.targetReps,
targetScore: row.targetScore,
targetScoreTimeMs: row.targetScoreTimeMs,
restSecondsOverride: row.restSecondsOverride,
);
}
@ -1132,6 +1231,7 @@ _workoutTemplateExerciseOverrideCompanion(
targetTimeSecondsOverride: Value(override.targetTimeSecondsOverride),
targetRepsOverride: Value(override.targetRepsOverride),
targetScoreOverride: Value(override.targetScoreOverride),
targetScoreTimeMsOverride: Value(override.targetScoreTimeMsOverride),
);
}
@ -1146,6 +1246,7 @@ domain.WorkoutTemplateExerciseOverride _workoutTemplateExerciseOverrideFromRow(
targetTimeSecondsOverride: row.targetTimeSecondsOverride,
targetRepsOverride: row.targetRepsOverride,
targetScoreOverride: row.targetScoreOverride,
targetScoreTimeMsOverride: row.targetScoreTimeMsOverride,
);
}
@ -1225,6 +1326,10 @@ db.ActiveSetResultsCompanion _activeSetResultCompanion(
actualTimeMs: Value(result.actualTimeMs),
actualReps: Value(result.actualReps),
actualScore: Value(result.actualScore),
actualScoreTimeMs: Value(result.actualScoreTimeMs),
scoreInputModeSnapshot: Value(
_scoreInputModeToDb(result.scoreInputModeSnapshot),
),
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
note: Value(result.note),
@ -1246,6 +1351,8 @@ domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) {
actualTimeMs: row.actualTimeMs,
actualReps: row.actualReps,
actualScore: row.actualScore,
actualScoreTimeMs: row.actualScoreTimeMs,
scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot),
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
note: row.note,
@ -1296,6 +1403,49 @@ domain.ActiveRestState _activeRestStateFromRow(db.ActiveRestState row) {
);
}
db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
domain.ActiveScoreStopwatchState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveScoreStopwatchStatesCompanion(
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),
status: Value(_scoreStopwatchStatusToDb(state.status)),
startedAt: Value(state.startedAt.toUtc()),
accumulatedMs: Value(state.accumulatedMs),
stoppedAt: Value(_utcOrNull(state.stoppedAt)),
);
}
domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
db.ActiveScoreStopwatchState row,
) {
return domain.ActiveScoreStopwatchState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
status: _scoreStopwatchStatusFromDb(row.status),
startedAt: _utc(row.startedAt),
accumulatedMs: row.accumulatedMs,
stoppedAt: _utcOrNull(row.stoppedAt),
);
}
db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
domain.WorkoutHistory history,
) {
@ -1350,12 +1500,17 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
timeEnabledSnapshot: Value(result.timeEnabledSnapshot),
repsEnabledSnapshot: Value(result.repsEnabledSnapshot),
scoreEnabledSnapshot: Value(result.scoreEnabledSnapshot),
scoreInputModeSnapshot: Value(
_scoreInputModeToDb(result.scoreInputModeSnapshot),
),
targetTimeSecondsSnapshot: Value(result.targetTimeSecondsSnapshot),
targetRepsSnapshot: Value(result.targetRepsSnapshot),
targetScoreSnapshot: Value(result.targetScoreSnapshot),
targetScoreTimeMsSnapshot: Value(result.targetScoreTimeMsSnapshot),
actualTimeMs: Value(result.actualTimeMs),
actualReps: Value(result.actualReps),
actualScore: Value(result.actualScore),
actualScoreTimeMs: Value(result.actualScoreTimeMs),
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
startedAt: Value(_utcOrNull(result.startedAt)),
@ -1398,12 +1553,15 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow(
timeEnabledSnapshot: row.timeEnabledSnapshot,
repsEnabledSnapshot: row.repsEnabledSnapshot,
scoreEnabledSnapshot: row.scoreEnabledSnapshot,
scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot),
targetTimeSecondsSnapshot: row.targetTimeSecondsSnapshot,
targetRepsSnapshot: row.targetRepsSnapshot,
targetScoreSnapshot: row.targetScoreSnapshot,
targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot,
actualTimeMs: row.actualTimeMs,
actualReps: row.actualReps,
actualScore: row.actualScore,
actualScoreTimeMs: row.actualScoreTimeMs,
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
startedAt: _utcOrNull(row.startedAt),
@ -1446,6 +1604,17 @@ domain.MediaKind _mediaKindFromDb(String value) => switch (value) {
_ => throw domain.DomainException('Unknown media kind: $value'),
};
String _scoreInputModeToDb(domain.ScoreInputMode mode) => switch (mode) {
domain.ScoreInputMode.manual => 'manual',
domain.ScoreInputMode.stopwatch => 'stopwatch',
};
domain.ScoreInputMode _scoreInputModeFromDb(String value) => switch (value) {
'manual' => domain.ScoreInputMode.manual,
'stopwatch' => domain.ScoreInputMode.stopwatch,
_ => throw domain.DomainException('Unknown score input mode: $value'),
};
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
domain.SetResultStatus.completed => 'completed',
domain.SetResultStatus.skipped => 'skipped',
@ -1457,6 +1626,21 @@ domain.SetResultStatus _setResultStatusFromDb(String value) => switch (value) {
_ => throw domain.DomainException('Unknown set result status: $value'),
};
String _scoreStopwatchStatusToDb(domain.ActiveScoreStopwatchStatus status) =>
switch (status) {
domain.ActiveScoreStopwatchStatus.running => 'running',
domain.ActiveScoreStopwatchStatus.stopped => 'stopped',
};
domain.ActiveScoreStopwatchStatus _scoreStopwatchStatusFromDb(String value) =>
switch (value) {
'running' => domain.ActiveScoreStopwatchStatus.running,
'stopped' => domain.ActiveScoreStopwatchStatus.stopped,
_ => throw domain.DomainException(
'Unknown active score stopwatch status: $value',
),
};
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
'running' => domain.ActiveWorkoutStatus.running,
'paused' => domain.ActiveWorkoutStatus.paused,

View File

@ -65,6 +65,8 @@ class Exercises extends SyncableTable {
BoolColumn get hasTimeMeasure => boolean()();
BoolColumn get hasRepsMeasure => boolean()();
BoolColumn get hasScoreMeasure => boolean()();
TextColumn get scoreInputMode =>
text().withDefault(const Constant('manual'))();
TextColumn get scoreLabel => text().nullable()();
TextColumn get scoreUnit => text().nullable()();
DateTimeColumn get archivedAt => dateTime().nullable()();
@ -72,9 +74,11 @@ class Exercises extends SyncableTable {
@override
List<String> get customConstraints => [
'CHECK (has_time_measure OR has_reps_measure OR has_score_measure)',
'CHECK (NOT has_score_measure OR (score_label IS NOT NULL '
'AND length(trim(score_label)) > 0 AND score_unit IS NOT NULL '
'AND length(trim(score_unit)) > 0))',
"CHECK (score_input_mode IN ('manual', 'stopwatch'))",
"CHECK (has_score_measure OR score_input_mode = 'manual')",
"CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR "
'(score_label IS NOT NULL AND length(trim(score_label)) > 0 '
'AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0))',
];
}
@ -111,6 +115,8 @@ class ProgramExercises extends SyncableTable {
BoolColumn get availableTimeSnapshot => boolean()();
BoolColumn get availableRepsSnapshot => boolean()();
BoolColumn get availableScoreSnapshot => boolean()();
TextColumn get scoreInputModeSnapshot =>
text().withDefault(const Constant('manual'))();
TextColumn get scoreLabelSnapshot => text().nullable()();
TextColumn get scoreUnitSnapshot => text().nullable()();
IntColumn get setsCount => integer()();
@ -120,6 +126,7 @@ class ProgramExercises extends SyncableTable {
IntColumn get targetTimeSeconds => integer().nullable()();
IntColumn get targetReps => integer().nullable()();
RealColumn get targetScore => real().nullable()();
IntColumn get targetScoreTimeMs => integer().nullable()();
IntColumn get restSecondsOverride => integer().nullable()();
@override
@ -134,11 +141,18 @@ class ProgramExercises extends SyncableTable {
'CHECK (target_time_seconds IS NULL OR target_time_seconds > 0)',
'CHECK (target_reps IS NULL OR target_reps > 0)',
'CHECK (target_score IS NULL OR target_score >= 0)',
'CHECK (target_score_time_ms IS NULL OR target_score_time_ms > 0)',
'CHECK (rest_seconds_override IS NULL OR rest_seconds_override >= 0)',
'CHECK (target_time_seconds IS NULL OR time_enabled)',
'CHECK (target_reps IS NULL OR reps_enabled)',
'CHECK (target_score IS NULL OR score_enabled)',
'CHECK (NOT available_score_snapshot OR (score_label_snapshot '
"CHECK (score_input_mode_snapshot IN ('manual', 'stopwatch'))",
'CHECK (target_score IS NULL OR '
"(score_enabled AND score_input_mode_snapshot = 'manual'))",
'CHECK (target_score_time_ms IS NULL OR '
"(score_enabled AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (target_score IS NULL OR target_score_time_ms IS NULL)',
"CHECK (score_input_mode_snapshot != 'manual' OR "
'NOT available_score_snapshot OR (score_label_snapshot '
'IS NOT NULL AND length(trim(score_label_snapshot)) > 0 '
'AND score_unit_snapshot IS NOT NULL '
'AND length(trim(score_unit_snapshot)) > 0))',
@ -185,6 +199,7 @@ class WorkoutTemplateExerciseOverrides extends SyncableTable {
IntColumn get targetTimeSecondsOverride => integer().nullable()();
IntColumn get targetRepsOverride => integer().nullable()();
RealColumn get targetScoreOverride => real().nullable()();
IntColumn get targetScoreTimeMsOverride => integer().nullable()();
@override
List<String> get customConstraints => [
@ -194,6 +209,10 @@ class WorkoutTemplateExerciseOverrides extends SyncableTable {
'target_time_seconds_override > 0)',
'CHECK (target_reps_override IS NULL OR target_reps_override > 0)',
'CHECK (target_score_override IS NULL OR target_score_override >= 0)',
'CHECK (target_score_time_ms_override IS NULL OR '
'target_score_time_ms_override > 0)',
'CHECK (target_score_override IS NULL OR '
'target_score_time_ms_override IS NULL)',
];
}
@ -241,6 +260,9 @@ class ActiveSetResults extends SyncableTable {
IntColumn get actualTimeMs => integer().nullable()();
IntColumn get actualReps => integer().nullable()();
RealColumn get actualScore => real().nullable()();
IntColumn get actualScoreTimeMs => integer().nullable()();
TextColumn get scoreInputModeSnapshot =>
text().withDefault(const Constant('manual'))();
TextColumn get scoreLabelSnapshot => text().nullable()();
TextColumn get scoreUnitSnapshot => text().nullable()();
TextColumn get note => text().nullable()();
@ -256,9 +278,17 @@ class ActiveSetResults extends SyncableTable {
'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 (status IN ('completed', 'skipped'))",
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
'AND actual_reps IS NULL AND actual_score IS NULL))',
'AND actual_reps IS NULL AND actual_score IS NULL '
'AND actual_score_time_ms IS NULL))',
"CHECK (score_input_mode_snapshot IN ('manual', 'stopwatch'))",
'CHECK (actual_score IS NULL OR '
"score_input_mode_snapshot = 'manual')",
'CHECK (actual_score_time_ms IS NULL OR '
"score_input_mode_snapshot = 'stopwatch')",
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
'CHECK (actual_score IS NULL OR (score_label_snapshot IS NOT NULL '
'AND length(trim(score_label_snapshot)) > 0 '
'AND score_unit_snapshot IS NOT NULL '
@ -266,6 +296,32 @@ class ActiveSetResults extends SyncableTable {
];
}
class ActiveScoreStopwatchStates extends SyncableTable {
@override
String get tableName => 'active_score_stopwatch_states';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
TextColumn get status => text()();
DateTimeColumn get startedAt => dateTime()();
IntColumn get accumulatedMs => integer()();
DateTimeColumn get stoppedAt => dateTime().nullable()();
@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 (status IN ('running', 'stopped'))",
'CHECK (accumulated_ms >= 0)',
];
}
class ActiveRestStates extends SyncableTable {
@override
String get tableName => 'active_rest_states';
@ -326,12 +382,16 @@ class WorkoutHistorySetResults extends SyncableTable {
BoolColumn get timeEnabledSnapshot => boolean()();
BoolColumn get repsEnabledSnapshot => boolean()();
BoolColumn get scoreEnabledSnapshot => boolean()();
TextColumn get scoreInputModeSnapshot =>
text().withDefault(const Constant('manual'))();
IntColumn get targetTimeSecondsSnapshot => integer().nullable()();
IntColumn get targetRepsSnapshot => integer().nullable()();
RealColumn get targetScoreSnapshot => real().nullable()();
IntColumn get targetScoreTimeMsSnapshot => integer().nullable()();
IntColumn get actualTimeMs => integer().nullable()();
IntColumn get actualReps => integer().nullable()();
RealColumn get actualScore => real().nullable()();
IntColumn get actualScoreTimeMs => integer().nullable()();
TextColumn get scoreLabelSnapshot => text().nullable()();
TextColumn get scoreUnitSnapshot => text().nullable()();
DateTimeColumn get startedAt => dateTime().nullable()();
@ -351,12 +411,28 @@ class WorkoutHistorySetResults extends SyncableTable {
'target_time_seconds_snapshot > 0)',
'CHECK (target_reps_snapshot IS NULL OR target_reps_snapshot > 0)',
'CHECK (target_score_snapshot IS NULL OR target_score_snapshot >= 0)',
'CHECK (target_score_time_ms_snapshot IS NULL OR '
'target_score_time_ms_snapshot > 0)',
'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 (status IN ('completed', 'skipped'))",
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
'AND actual_reps IS NULL AND actual_score IS NULL))',
'AND actual_reps IS NULL AND actual_score IS NULL '
'AND actual_score_time_ms IS NULL))',
"CHECK (score_input_mode_snapshot IN ('manual', 'stopwatch'))",
'CHECK (target_score_snapshot IS NULL OR '
"(score_enabled_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (target_score_time_ms_snapshot IS NULL OR '
"(score_enabled_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (target_score_snapshot IS NULL OR '
'target_score_time_ms_snapshot IS NULL)',
'CHECK (actual_score IS NULL OR '
"score_input_mode_snapshot = 'manual')",
'CHECK (actual_score_time_ms IS NULL OR '
"score_input_mode_snapshot = 'stopwatch')",
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
'CHECK (actual_score IS NULL OR (score_label_snapshot IS NOT NULL '
'AND length(trim(score_label_snapshot)) > 0 '
'AND score_unit_snapshot IS NOT NULL '

View File

@ -326,6 +326,124 @@ void main() {
]);
},
);
test(
'score stopwatch start stop resume accumulates elapsed duration',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
await useCase.startScoreStopwatch(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(seconds: 5));
final stopped = await useCase.stopScoreStopwatch(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(seconds: 10));
await useCase.resumeScoreStopwatch(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(seconds: 3));
final stoppedAgain = await useCase.stopScoreStopwatch(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(stopped.accumulatedMs, 5000);
expect(stoppedAgain.accumulatedMs, 8000);
},
);
test('score stopwatch state survives use case reconstruction', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
);
final repository = _FakeActiveSessionRepository()..session = session;
await _activeUseCase(repository, clock).startScoreStopwatch(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(seconds: 4));
final reconstructed = _activeUseCase(repository, clock);
final stopped = await reconstructed.stopScoreStopwatch(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(stopped.accumulatedMs, 4000);
});
test('score result enforces manual xor stopwatch values', () {
expect(
() => ActiveSetResult(
metadata: _metadata('result-1'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 12,
actualScoreTimeMs: 12000,
),
throwsA(isA<DomainException>()),
);
expect(
() => ActiveSetResult(
metadata: _metadata('result-2'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 12,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
),
throwsA(isA<DomainException>()),
);
expect(
() => ActiveSetResult(
metadata: _metadata('result-3'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScoreTimeMs: 12000,
scoreInputModeSnapshot: ScoreInputMode.manual,
),
throwsA(isA<DomainException>()),
);
});
}
EntityMetadata _metadata(String id) {
@ -414,6 +532,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final results = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
@override
Future<ActiveWorkoutSession?> findById(String id) async {
@ -426,6 +545,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveRestState?> findRestStateById(String id) async => restStates[id];
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return scoreStopwatchStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null,
)
.firstOrNull;
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates.values
@ -433,6 +571,19 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -450,6 +601,31 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final state = await findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
return;
}
scoreStopwatchStates.remove(state.metadata.id);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.removeWhere(

View File

@ -287,11 +287,26 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
@ -303,6 +318,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}

View File

@ -545,6 +545,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
@override
Future<ActiveWorkoutSession?> findById(String id) async {
@ -564,6 +565,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in scoreStopwatchStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates
@ -571,6 +591,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
@ -593,6 +622,27 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
final index = scoreStopwatchStates.indexWhere(
(saved) => saved.metadata.id == state.metadata.id,
);
if (index == -1) {
scoreStopwatchStates.add(state);
} else {
scoreStopwatchStates[index] = state;
}
}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
final index = results.indexWhere(