Files
GameTime/lib/domain/entities.dart
Blomios b4275d64d8 feat(application): couche application avec use cases et repositories
Ajoute les entités du domaine (domain/entities.dart), les ports de
repository (application/ports.dart), les use cases (application/use_cases.dart)
et leur implémentation Drift (infrastructure/local/drift_repositories.dart).
flutter analyze propre, tests unitaires verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:26:47 +02:00

743 lines
22 KiB
Dart

import 'dart:convert';
enum SyncState { localOnly, dirty, synced, deleted }
enum MediaKind { image, video }
enum WorkoutMeasure { time, reps, score }
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
final class DomainException implements Exception {
const DomainException(this.message);
final String message;
@override
String toString() => message;
}
final class EntityMetadata {
const EntityMetadata({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.originDeviceId,
this.deletedAt,
this.schemaVersion = 1,
this.syncState = SyncState.dirty,
this.localRevision = 0,
this.futureOwnerProfileId,
this.lastSyncedAt,
this.remoteRevision,
});
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? deletedAt;
final int schemaVersion;
final SyncState syncState;
final int localRevision;
final String originDeviceId;
final String? futureOwnerProfileId;
final DateTime? lastSyncedAt;
final String? remoteRevision;
EntityMetadata touch(DateTime now) {
return copyWith(updatedAt: now, localRevision: localRevision + 1);
}
EntityMetadata markDeleted(DateTime now) {
return copyWith(
updatedAt: now,
deletedAt: now,
syncState: SyncState.deleted,
localRevision: localRevision + 1,
);
}
EntityMetadata copyWith({
DateTime? createdAt,
DateTime? updatedAt,
DateTime? deletedAt,
int? schemaVersion,
SyncState? syncState,
int? localRevision,
String? originDeviceId,
String? futureOwnerProfileId,
DateTime? lastSyncedAt,
String? remoteRevision,
}) {
return EntityMetadata(
id: id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
schemaVersion: schemaVersion ?? this.schemaVersion,
syncState: syncState ?? this.syncState,
localRevision: localRevision ?? this.localRevision,
originDeviceId: originDeviceId ?? this.originDeviceId,
futureOwnerProfileId: futureOwnerProfileId ?? this.futureOwnerProfileId,
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
remoteRevision: remoteRevision ?? this.remoteRevision,
);
}
}
final class MediaAsset {
const MediaAsset({
required this.metadata,
required this.kind,
required this.localUri,
this.mimeType,
this.sizeBytes,
this.width,
this.height,
this.durationMs,
this.checksum,
this.remoteUri,
this.thumbnailLocalUri,
});
final EntityMetadata metadata;
final MediaKind kind;
final String localUri;
final String? mimeType;
final int? sizeBytes;
final int? width;
final int? height;
final int? durationMs;
final String? checksum;
final String? remoteUri;
final String? thumbnailLocalUri;
}
final class Exercise {
Exercise({
required this.metadata,
required String name,
this.description,
this.imageMediaId,
this.videoMediaId,
required this.hasTimeMeasure,
required this.hasRepsMeasure,
required this.hasScoreMeasure,
this.scoreLabel,
this.scoreUnit,
this.archivedAt,
}) : name = _nonBlank(name, 'Exercise name') {
_requireAtLeastOneMeasure(
hasTime: hasTimeMeasure,
hasReps: hasRepsMeasure,
hasScore: hasScoreMeasure,
);
if (hasScoreMeasure) {
_nonBlank(scoreLabel, 'Score label');
_nonBlank(scoreUnit, 'Score unit');
}
}
final EntityMetadata metadata;
final String name;
final String? description;
final String? imageMediaId;
final String? videoMediaId;
final bool hasTimeMeasure;
final bool hasRepsMeasure;
final bool hasScoreMeasure;
final String? scoreLabel;
final String? scoreUnit;
final DateTime? archivedAt;
Set<WorkoutMeasure> get availableMeasures => {
if (hasTimeMeasure) WorkoutMeasure.time,
if (hasRepsMeasure) WorkoutMeasure.reps,
if (hasScoreMeasure) WorkoutMeasure.score,
};
Exercise archive(DateTime now) {
return copyWith(archivedAt: now, metadata: metadata.touch(now));
}
Exercise copyWith({
EntityMetadata? metadata,
String? name,
String? description,
String? imageMediaId,
String? videoMediaId,
bool? hasTimeMeasure,
bool? hasRepsMeasure,
bool? hasScoreMeasure,
String? scoreLabel,
String? scoreUnit,
DateTime? archivedAt,
}) {
return Exercise(
metadata: metadata ?? this.metadata,
name: name ?? this.name,
description: description ?? this.description,
imageMediaId: imageMediaId ?? this.imageMediaId,
videoMediaId: videoMediaId ?? this.videoMediaId,
hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure ?? this.hasScoreMeasure,
scoreLabel: scoreLabel ?? this.scoreLabel,
scoreUnit: scoreUnit ?? this.scoreUnit,
archivedAt: archivedAt ?? this.archivedAt,
);
}
}
final class Program {
Program({
required this.metadata,
required String name,
required this.defaultRestSeconds,
this.exercises = const [],
}) : name = _nonBlank(name, 'Program name') {
_requireNonNegative(defaultRestSeconds, 'Default rest seconds');
}
final EntityMetadata metadata;
final String name;
final int defaultRestSeconds;
final List<ProgramExercise> exercises;
Program copyWith({
List<ProgramExercise>? exercises,
EntityMetadata? metadata,
}) {
return Program(
metadata: metadata ?? this.metadata,
name: name,
defaultRestSeconds: defaultRestSeconds,
exercises: exercises ?? this.exercises,
);
}
}
final class ProgramExercise {
ProgramExercise({
required this.metadata,
required this.programId,
this.sourceExerciseId,
required this.position,
required String exerciseNameSnapshot,
this.exerciseDescriptionSnapshot,
this.exerciseImageMediaIdSnapshot,
this.exerciseVideoMediaIdSnapshot,
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
required this.availableScoreSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.restSecondsOverride,
}) : exerciseNameSnapshot = _nonBlank(
exerciseNameSnapshot,
'Exercise snapshot name',
) {
_requireNonNegative(position, 'Position');
_requirePositive(setsCount, 'Sets count');
_requireAtLeastOneMeasure(
hasTime: timeEnabled,
hasReps: repsEnabled,
hasScore: scoreEnabled,
);
if (timeEnabled && !availableTimeSnapshot) {
throw const DomainException('Time is not available on this exercise.');
}
if (repsEnabled && !availableRepsSnapshot) {
throw const DomainException('Reps are not available on this exercise.');
}
if (scoreEnabled && !availableScoreSnapshot) {
throw const DomainException('Score is not available on this exercise.');
}
_requireNullablePositive(targetTimeSeconds, 'Target time seconds');
_requireNullablePositive(targetReps, 'Target reps');
_requireNullableNonNegativeDouble(targetScore, 'Target score');
_requireNullableNonNegative(restSecondsOverride, 'Rest seconds override');
}
final EntityMetadata metadata;
final String programId;
final String? sourceExerciseId;
final int position;
final String exerciseNameSnapshot;
final String? exerciseDescriptionSnapshot;
final String? exerciseImageMediaIdSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
final bool availableScoreSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final int setsCount;
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? restSecondsOverride;
static ProgramExercise snapshotFromExercise({
required EntityMetadata metadata,
required String programId,
required Exercise exercise,
required int position,
required int setsCount,
required Set<WorkoutMeasure> enabledMeasures,
int? targetTimeSeconds,
int? targetReps,
double? targetScore,
int? restSecondsOverride,
}) {
final available = exercise.availableMeasures;
if (!available.containsAll(enabledMeasures)) {
throw const DomainException(
'Enabled measures must be a subset of exercise measures.',
);
}
return ProgramExercise(
metadata: metadata,
programId: programId,
sourceExerciseId: exercise.metadata.id,
position: position,
exerciseNameSnapshot: exercise.name,
exerciseDescriptionSnapshot: exercise.description,
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseArchivedSnapshot: exercise.archivedAt != null,
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
availableScoreSnapshot: exercise.hasScoreMeasure,
scoreLabelSnapshot: exercise.scoreLabel,
scoreUnitSnapshot: exercise.scoreUnit,
setsCount: setsCount,
timeEnabled: enabledMeasures.contains(WorkoutMeasure.time),
repsEnabled: enabledMeasures.contains(WorkoutMeasure.reps),
scoreEnabled: enabledMeasures.contains(WorkoutMeasure.score),
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
restSecondsOverride: restSecondsOverride,
);
}
Map<String, Object?> toSnapshotJson() => {
'id': metadata.id,
'sourceExerciseId': sourceExerciseId,
'position': position,
'exerciseNameSnapshot': exerciseNameSnapshot,
'exerciseDescriptionSnapshot': exerciseDescriptionSnapshot,
'exerciseImageMediaIdSnapshot': exerciseImageMediaIdSnapshot,
'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot,
'exerciseArchivedSnapshot': exerciseArchivedSnapshot,
'availableTimeSnapshot': availableTimeSnapshot,
'availableRepsSnapshot': availableRepsSnapshot,
'availableScoreSnapshot': availableScoreSnapshot,
'scoreLabelSnapshot': scoreLabelSnapshot,
'scoreUnitSnapshot': scoreUnitSnapshot,
'setsCount': setsCount,
'timeEnabled': timeEnabled,
'repsEnabled': repsEnabled,
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': targetReps,
'targetScore': targetScore,
'restSecondsOverride': restSecondsOverride,
};
}
final class WorkoutTemplate {
WorkoutTemplate({
required this.metadata,
required String name,
this.lastStartedAt,
this.programs = const [],
this.overrides = const [],
}) : name = _nonBlank(name, 'Workout template name');
final EntityMetadata metadata;
final String name;
final DateTime? lastStartedAt;
final List<WorkoutTemplateProgram> programs;
final List<WorkoutTemplateExerciseOverride> overrides;
}
final class WorkoutTemplateProgram {
WorkoutTemplateProgram({
required this.metadata,
required this.workoutTemplateId,
this.sourceProgramId,
required this.position,
required String programNameSnapshot,
required this.defaultRestSecondsSnapshot,
required this.programSnapshotJson,
}) : programNameSnapshot = _nonBlank(
programNameSnapshot,
'Program snapshot name',
) {
_requireNonNegative(position, 'Position');
_requireNonNegative(defaultRestSecondsSnapshot, 'Default rest seconds');
}
final EntityMetadata metadata;
final String workoutTemplateId;
final String? sourceProgramId;
final int position;
final String programNameSnapshot;
final int defaultRestSecondsSnapshot;
final String programSnapshotJson;
static WorkoutTemplateProgram snapshotFromProgram({
required EntityMetadata metadata,
required String workoutTemplateId,
required Program program,
required int position,
}) {
return WorkoutTemplateProgram(
metadata: metadata,
workoutTemplateId: workoutTemplateId,
sourceProgramId: program.metadata.id,
position: position,
programNameSnapshot: program.name,
defaultRestSecondsSnapshot: program.defaultRestSeconds,
programSnapshotJson: jsonEncode({
'programId': program.metadata.id,
'name': program.name,
'defaultRestSeconds': program.defaultRestSeconds,
'exercises': program.exercises
.map((exercise) => exercise.toSnapshotJson())
.toList(),
}),
);
}
}
final class WorkoutTemplateExerciseOverride {
WorkoutTemplateExerciseOverride({
required this.metadata,
required this.workoutTemplateProgramId,
required String snapshotProgramExerciseId,
this.setsCountOverride,
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
}) : snapshotProgramExerciseId = _nonBlank(
snapshotProgramExerciseId,
'Snapshot program exercise id',
) {
_requireNullablePositive(setsCountOverride, 'Sets count override');
_requireNullablePositive(
targetTimeSecondsOverride,
'Target time seconds override',
);
_requireNullablePositive(targetRepsOverride, 'Target reps override');
_requireNullableNonNegativeDouble(
targetScoreOverride,
'Target score override',
);
}
final EntityMetadata metadata;
final String workoutTemplateProgramId;
final String snapshotProgramExerciseId;
final int? setsCountOverride;
final int? targetTimeSecondsOverride;
final int? targetRepsOverride;
final double? targetScoreOverride;
}
final class ActiveWorkoutSession {
ActiveWorkoutSession({
required this.metadata,
this.sourceWorkoutTemplateId,
required this.status,
required this.startedAt,
this.pausedAt,
this.endedAt,
required this.lastPersistedAt,
required this.elapsedActiveMs,
required this.currentProgramIndex,
required this.currentExerciseIndex,
required this.currentSetIndex,
required this.resolvedTemplateSnapshotJson,
}) {
_requireNonNegative(elapsedActiveMs, 'Elapsed active milliseconds');
_requireNonNegative(currentProgramIndex, 'Current program index');
_requireNonNegative(currentExerciseIndex, 'Current exercise index');
_requireNonNegative(currentSetIndex, 'Current set index');
}
final EntityMetadata metadata;
final String? sourceWorkoutTemplateId;
final ActiveWorkoutStatus status;
final DateTime startedAt;
final DateTime? pausedAt;
final DateTime? endedAt;
final DateTime lastPersistedAt;
final int elapsedActiveMs;
final int currentProgramIndex;
final int currentExerciseIndex;
final int currentSetIndex;
final String resolvedTemplateSnapshotJson;
int elapsedActiveMillisecondsAt(DateTime now) {
if (status != ActiveWorkoutStatus.running) {
return elapsedActiveMs;
}
return elapsedActiveMs + now.difference(lastPersistedAt).inMilliseconds;
}
ActiveWorkoutSession pause(DateTime now) {
return copyWith(
status: ActiveWorkoutStatus.paused,
pausedAt: now,
lastPersistedAt: now,
elapsedActiveMs: elapsedActiveMillisecondsAt(now),
metadata: metadata.touch(now),
);
}
ActiveWorkoutSession resume(DateTime now) {
return ActiveWorkoutSession(
metadata: metadata.touch(now),
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
status: ActiveWorkoutStatus.running,
startedAt: startedAt,
pausedAt: null,
endedAt: endedAt,
lastPersistedAt: now,
elapsedActiveMs: elapsedActiveMs,
currentProgramIndex: currentProgramIndex,
currentExerciseIndex: currentExerciseIndex,
currentSetIndex: currentSetIndex,
resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
);
}
ActiveWorkoutSession copyWith({
EntityMetadata? metadata,
ActiveWorkoutStatus? status,
DateTime? pausedAt,
DateTime? endedAt,
DateTime? lastPersistedAt,
int? elapsedActiveMs,
int? currentProgramIndex,
int? currentExerciseIndex,
int? currentSetIndex,
}) {
return ActiveWorkoutSession(
metadata: metadata ?? this.metadata,
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
status: status ?? this.status,
startedAt: startedAt,
pausedAt: pausedAt,
endedAt: endedAt ?? this.endedAt,
lastPersistedAt: lastPersistedAt ?? this.lastPersistedAt,
elapsedActiveMs: elapsedActiveMs ?? this.elapsedActiveMs,
currentProgramIndex: currentProgramIndex ?? this.currentProgramIndex,
currentExerciseIndex: currentExerciseIndex ?? this.currentExerciseIndex,
currentSetIndex: currentSetIndex ?? this.currentSetIndex,
resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
);
}
}
final class ActiveSetResult {
const ActiveSetResult({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
this.startedAt,
this.completedAt,
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.note,
});
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final DateTime? startedAt;
final DateTime? completedAt;
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final String? note;
}
final class ActiveRestState {
const ActiveRestState({
required this.metadata,
required this.activeWorkoutSessionId,
required this.afterProgramIndex,
required this.afterExerciseIndex,
required this.afterSetIndex,
required this.plannedRestSeconds,
required this.adjustedRestSeconds,
required this.startedAt,
this.endedAt,
this.skippedAt,
});
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final int afterProgramIndex;
final int afterExerciseIndex;
final int afterSetIndex;
final int plannedRestSeconds;
final int adjustedRestSeconds;
final DateTime startedAt;
final DateTime? endedAt;
final DateTime? skippedAt;
}
final class WorkoutHistory {
const WorkoutHistory({
required this.metadata,
this.sourceWorkoutTemplateId,
this.sourceActiveWorkoutSessionId,
required this.nameSnapshot,
required this.startedAt,
required this.endedAt,
required this.totalActiveMs,
required this.completed,
required this.historySnapshotJson,
this.results = const [],
});
final EntityMetadata metadata;
final String? sourceWorkoutTemplateId;
final String? sourceActiveWorkoutSessionId;
final String nameSnapshot;
final DateTime startedAt;
final DateTime endedAt;
final int totalActiveMs;
final bool completed;
final String historySnapshotJson;
final List<WorkoutHistorySetResult> results;
}
final class WorkoutHistorySetResult {
const WorkoutHistorySetResult({
required this.metadata,
required this.workoutHistoryId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.programNameSnapshot,
required this.exerciseNameSnapshot,
required this.timeEnabledSnapshot,
required this.repsEnabledSnapshot,
required this.scoreEnabledSnapshot,
this.targetTimeSecondsSnapshot,
this.targetRepsSnapshot,
this.targetScoreSnapshot,
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.startedAt,
this.completedAt,
});
final EntityMetadata metadata;
final String workoutHistoryId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final String programNameSnapshot;
final String exerciseNameSnapshot;
final bool timeEnabledSnapshot;
final bool repsEnabledSnapshot;
final bool scoreEnabledSnapshot;
final int? targetTimeSecondsSnapshot;
final int? targetRepsSnapshot;
final double? targetScoreSnapshot;
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final DateTime? startedAt;
final DateTime? completedAt;
}
String _nonBlank(String? value, String label) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
throw DomainException('$label must not be blank.');
}
return trimmed;
}
void _requireAtLeastOneMeasure({
required bool hasTime,
required bool hasReps,
required bool hasScore,
}) {
if (!hasTime && !hasReps && !hasScore) {
throw const DomainException('At least one measure must be active.');
}
}
void _requirePositive(int value, String label) {
if (value <= 0) {
throw DomainException('$label must be positive.');
}
}
void _requireNonNegative(int value, String label) {
if (value < 0) {
throw DomainException('$label must not be negative.');
}
}
void _requireNullablePositive(int? value, String label) {
if (value != null) {
_requirePositive(value, label);
}
}
void _requireNullableNonNegative(int? value, String label) {
if (value != null) {
_requireNonNegative(value, label);
}
}
void _requireNullableNonNegativeDouble(double? value, String label) {
if (value != null && value < 0) {
throw DomainException('$label must not be negative.');
}
}