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:
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user