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

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