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

Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités du domaine, les ports et use cases pour poser les fondations
du score chronométré. build_runner OK, flutter 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:14 +02:00
parent c88c4e876a
commit d521bdffb6
10 changed files with 3628 additions and 15 deletions

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