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>
This commit is contained in:
2026-07-17 18:26:47 +02:00
parent c010f19670
commit b4275d64d8
9 changed files with 2061 additions and 1 deletions

View File

@ -3,3 +3,6 @@
/// This layer owns use cases and ports. It may depend on domain, but concrete
/// adapters are wired only from the composition root.
library;
export 'ports.dart';
export 'use_cases.dart';

View File

@ -0,0 +1,49 @@
import '../domain/domain.dart';
abstract interface class Clock {
DateTime now();
}
final class SystemClock implements Clock {
const SystemClock();
@override
DateTime now() => DateTime.now().toUtc();
}
abstract interface class IdGenerator {
String newId();
}
abstract interface class ExerciseRepository {
Future<Exercise?> findById(String id);
Future<List<Exercise>> listActive();
Future<void> save(Exercise exercise);
}
abstract interface class ProgramRepository {
Future<Program?> findById(String id);
Future<void> save(Program program);
Future<void> saveExercise(ProgramExercise exercise);
}
abstract interface class WorkoutTemplateRepository {
Future<WorkoutTemplate?> findById(String id);
Future<void> save(WorkoutTemplate template);
Future<void> saveProgram(WorkoutTemplateProgram program);
Future<void> saveOverride(WorkoutTemplateExerciseOverride override);
}
abstract interface class ActiveSessionRepository {
Future<ActiveWorkoutSession?> findById(String id);
Future<void> save(ActiveWorkoutSession session);
Future<void> saveSetResult(ActiveSetResult result);
Future<void> saveRestState(ActiveRestState restState);
Future<List<ActiveSetResult>> listSetResults(String sessionId);
Future<List<ActiveRestState>> listRestStates(String sessionId);
}
abstract interface class WorkoutHistoryRepository {
Future<void> save(WorkoutHistory history);
Future<void> saveSetResult(WorkoutHistorySetResult result);
}

View File

@ -0,0 +1,390 @@
import 'dart:convert';
import '../domain/domain.dart';
import 'ports.dart';
final class ExerciseUseCases {
const ExerciseUseCases({
required this.repository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ExerciseRepository repository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<Exercise> create({
required String name,
String? description,
String? imageMediaId,
String? videoMediaId,
required bool hasTimeMeasure,
required bool hasRepsMeasure,
required bool hasScoreMeasure,
String? scoreLabel,
String? scoreUnit,
}) async {
final now = clock.now();
final exercise = Exercise(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
description: description,
imageMediaId: imageMediaId,
videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
);
await repository.save(exercise);
return exercise;
}
Future<void> save(Exercise exercise) => repository.save(exercise);
Future<Exercise> archive(String id) async {
final exercise = await repository.findById(id);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final archived = exercise.archive(clock.now());
await repository.save(archived);
return archived;
}
}
final class ProgramUseCases {
const ProgramUseCases({
required this.programRepository,
required this.exerciseRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ProgramRepository programRepository;
final ExerciseRepository exerciseRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<Program> create({
required String name,
required int defaultRestSeconds,
}) async {
final now = clock.now();
final program = Program(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
defaultRestSeconds: defaultRestSeconds,
);
await programRepository.save(program);
return program;
}
Future<ProgramExercise> addExercise({
required String programId,
required String exerciseId,
required int position,
required int setsCount,
required Set<WorkoutMeasure> enabledMeasures,
int? targetTimeSeconds,
int? targetReps,
double? targetScore,
int? restSecondsOverride,
}) async {
final source = await exerciseRepository.findById(exerciseId);
if (source == null) {
throw const DomainException('Exercise not found.');
}
final now = clock.now();
final programExercise = ProgramExercise.snapshotFromExercise(
metadata: _newMetadata(ids, originDeviceId, now),
programId: programId,
exercise: source,
position: position,
setsCount: setsCount,
enabledMeasures: enabledMeasures,
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
restSecondsOverride: restSecondsOverride,
);
await programRepository.saveExercise(programExercise);
return programExercise;
}
}
final class WorkoutTemplateUseCases {
const WorkoutTemplateUseCases({
required this.templateRepository,
required this.programRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final WorkoutTemplateRepository templateRepository;
final ProgramRepository programRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<WorkoutTemplate> create({required String name}) async {
final now = clock.now();
final template = WorkoutTemplate(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
);
await templateRepository.save(template);
return template;
}
Future<WorkoutTemplateProgram> addProgramSnapshot({
required String workoutTemplateId,
required String programId,
required int position,
}) async {
final program = await programRepository.findById(programId);
if (program == null) {
throw const DomainException('Program not found.');
}
final now = clock.now();
final snapshot = WorkoutTemplateProgram.snapshotFromProgram(
metadata: _newMetadata(ids, originDeviceId, now),
workoutTemplateId: workoutTemplateId,
program: program,
position: position,
);
await templateRepository.saveProgram(snapshot);
return snapshot;
}
Future<WorkoutTemplateExerciseOverride> overrideExerciseTargets({
required String workoutTemplateProgramId,
required String snapshotProgramExerciseId,
int? setsCountOverride,
int? targetTimeSecondsOverride,
int? targetRepsOverride,
double? targetScoreOverride,
Set<WorkoutMeasure>? attemptedMeasureOverride,
}) async {
if (attemptedMeasureOverride != null) {
throw const DomainException(
'Workout templates cannot override active measures.',
);
}
final now = clock.now();
final override = WorkoutTemplateExerciseOverride(
metadata: _newMetadata(ids, originDeviceId, now),
workoutTemplateProgramId: workoutTemplateProgramId,
snapshotProgramExerciseId: snapshotProgramExerciseId,
setsCountOverride: setsCountOverride,
targetTimeSecondsOverride: targetTimeSecondsOverride,
targetRepsOverride: targetRepsOverride,
targetScoreOverride: targetScoreOverride,
);
await templateRepository.saveOverride(override);
return override;
}
}
final class ActiveWorkoutSessionUseCases {
const ActiveWorkoutSessionUseCases({
required this.sessionRepository,
required this.templateRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ActiveSessionRepository sessionRepository;
final WorkoutTemplateRepository templateRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
final template = await templateRepository.findById(templateId);
if (template == null) {
throw const DomainException('Workout template not found.');
}
final now = clock.now();
final session = ActiveWorkoutSession(
metadata: _newMetadata(ids, originDeviceId, now),
sourceWorkoutTemplateId: templateId,
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({
'templateId': template.metadata.id,
'name': template.name,
'programs': template.programs
.map(
(program) => {
'id': program.metadata.id,
'programNameSnapshot': program.programNameSnapshot,
'programSnapshotJson': program.programSnapshotJson,
},
)
.toList(),
'overrides': template.overrides
.map(
(override) => {
'workoutTemplateProgramId': override.workoutTemplateProgramId,
'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
'setsCountOverride': override.setsCountOverride,
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
'targetRepsOverride': override.targetRepsOverride,
'targetScoreOverride': override.targetScoreOverride,
},
)
.toList(),
}),
);
await sessionRepository.save(session);
return session;
}
Future<ActiveWorkoutSession> pause(String sessionId) async {
final session = await _requiredSession(sessionId);
final paused = session.pause(clock.now());
await sessionRepository.save(paused);
return paused;
}
Future<ActiveWorkoutSession> resume(String sessionId) async {
final session = await _requiredSession(sessionId);
final resumed = session.resume(clock.now());
await sessionRepository.save(resumed);
return resumed;
}
int elapsedActiveMilliseconds(ActiveWorkoutSession session) {
return session.elapsedActiveMillisecondsAt(clock.now());
}
Future<ActiveSetResult> recordSetResult(ActiveSetResult result) async {
await sessionRepository.saveSetResult(result);
return result;
}
Future<ActiveRestState> startRestAfterSet({
required String sessionId,
required int afterProgramIndex,
required int afterExerciseIndex,
required int afterSetIndex,
required int plannedRestSeconds,
int? adjustedRestSeconds,
}) async {
final now = clock.now();
final rest = ActiveRestState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
afterProgramIndex: afterProgramIndex,
afterExerciseIndex: afterExerciseIndex,
afterSetIndex: afterSetIndex,
plannedRestSeconds: plannedRestSeconds,
adjustedRestSeconds: adjustedRestSeconds ?? plannedRestSeconds,
startedAt: now,
);
await sessionRepository.saveRestState(rest);
return rest;
}
Future<ActiveWorkoutSession> _requiredSession(String sessionId) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {
throw const DomainException('Active workout session not found.');
}
return session;
}
}
final class CloseWorkoutSessionUseCase {
const CloseWorkoutSessionUseCase({
required this.sessionRepository,
required this.historyRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ActiveSessionRepository sessionRepository;
final WorkoutHistoryRepository historyRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<WorkoutHistory> close({
required String sessionId,
required String nameSnapshot,
required bool completed,
}) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {
throw const DomainException('Active workout session not found.');
}
final now = clock.now();
final results = await sessionRepository.listSetResults(sessionId);
final historyId = ids.newId();
final history = WorkoutHistory(
metadata: EntityMetadata(
id: historyId,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
),
sourceWorkoutTemplateId: session.sourceWorkoutTemplateId,
sourceActiveWorkoutSessionId: session.metadata.id,
nameSnapshot: nameSnapshot,
startedAt: session.startedAt,
endedAt: now,
totalActiveMs: session.elapsedActiveMillisecondsAt(now),
completed: completed,
historySnapshotJson: jsonEncode({
'sessionId': session.metadata.id,
'resolvedTemplateSnapshotJson': session.resolvedTemplateSnapshotJson,
'results': results
.map(
(result) => {
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
},
)
.toList(),
}),
);
await historyRepository.save(history);
return history;
}
}
EntityMetadata _newMetadata(
IdGenerator ids,
String originDeviceId,
DateTime now,
) {
return EntityMetadata(
id: ids.newId(),
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
);
}

View File

@ -2,3 +2,5 @@
///
/// Keep this layer free from Flutter, Drift, platform APIs, and IO.
library;
export 'entities.dart';

742
lib/domain/entities.dart Normal file
View File

@ -0,0 +1,742 @@
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.');
}
}

View File

@ -0,0 +1,751 @@
import 'package:drift/drift.dart';
import '../../application/application.dart';
import '../../domain/domain.dart' as domain;
import 'app_database.dart' as db;
final class DriftExerciseRepository implements ExerciseRepository {
const DriftExerciseRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.Exercise?> findById(String id) async {
final row = await (database.select(
database.exercises,
)..where((table) => table.id.equals(id))).getSingleOrNull();
return row == null ? null : _exerciseFromRow(row);
}
@override
Future<List<domain.Exercise>> listActive() async {
final rows =
await (database.select(database.exercises)
..where((table) => table.deletedAt.isNull())
..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get();
return rows.map(_exerciseFromRow).toList();
}
@override
Future<void> save(domain.Exercise exercise) async {
await database
.into(database.exercises)
.insertOnConflictUpdate(_exerciseCompanion(exercise));
}
}
final class DriftProgramRepository implements ProgramRepository {
const DriftProgramRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.Program?> findById(String id) async {
final row = await (database.select(
database.programs,
)..where((table) => table.id.equals(id))).getSingleOrNull();
if (row == null) {
return null;
}
final exercises =
await (database.select(database.programExercises)
..where((table) => table.programId.equals(id))
..orderBy([(table) => OrderingTerm.asc(table.position)]))
.get();
return _programFromRow(
row,
exercises.map(_programExerciseFromRow).toList(),
);
}
@override
Future<void> save(domain.Program program) async {
await database
.into(database.programs)
.insertOnConflictUpdate(_programCompanion(program));
for (final exercise in program.exercises) {
await saveExercise(exercise);
}
}
@override
Future<void> saveExercise(domain.ProgramExercise exercise) async {
await database
.into(database.programExercises)
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
}
}
final class DriftWorkoutTemplateRepository
implements WorkoutTemplateRepository {
const DriftWorkoutTemplateRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.WorkoutTemplate?> findById(String id) async {
final row = await (database.select(
database.workoutTemplates,
)..where((table) => table.id.equals(id))).getSingleOrNull();
if (row == null) {
return null;
}
final programs =
await (database.select(database.workoutTemplatePrograms)
..where((table) => table.workoutTemplateId.equals(id))
..orderBy([(table) => OrderingTerm.asc(table.position)]))
.get();
final programIds = programs.map((program) => program.id).toSet();
final overrides = await (database.select(
database.workoutTemplateExerciseOverrides,
)..where((table) => table.workoutTemplateProgramId.isIn(programIds))).get();
return _workoutTemplateFromRow(
row,
programs.map(_workoutTemplateProgramFromRow).toList(),
overrides.map(_workoutTemplateExerciseOverrideFromRow).toList(),
);
}
@override
Future<void> save(domain.WorkoutTemplate template) async {
await database
.into(database.workoutTemplates)
.insertOnConflictUpdate(_workoutTemplateCompanion(template));
for (final program in template.programs) {
await saveProgram(program);
}
for (final override in template.overrides) {
await saveOverride(override);
}
}
@override
Future<void> saveProgram(domain.WorkoutTemplateProgram program) async {
await database
.into(database.workoutTemplatePrograms)
.insertOnConflictUpdate(_workoutTemplateProgramCompanion(program));
}
@override
Future<void> saveOverride(
domain.WorkoutTemplateExerciseOverride override,
) async {
await database
.into(database.workoutTemplateExerciseOverrides)
.insertOnConflictUpdate(
_workoutTemplateExerciseOverrideCompanion(override),
);
}
}
final class DriftActiveSessionRepository implements ActiveSessionRepository {
const DriftActiveSessionRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.ActiveWorkoutSession?> findById(String id) async {
final row = await (database.select(
database.activeWorkoutSessions,
)..where((table) => table.id.equals(id))).getSingleOrNull();
return row == null ? null : _activeWorkoutSessionFromRow(row);
}
@override
Future<void> save(domain.ActiveWorkoutSession session) async {
await database
.into(database.activeWorkoutSessions)
.insertOnConflictUpdate(_activeWorkoutSessionCompanion(session));
}
@override
Future<void> saveSetResult(domain.ActiveSetResult result) async {
await database
.into(database.activeSetResults)
.insertOnConflictUpdate(_activeSetResultCompanion(result));
}
@override
Future<void> saveRestState(domain.ActiveRestState restState) async {
await database
.into(database.activeRestStates)
.insertOnConflictUpdate(_activeRestStateCompanion(restState));
}
@override
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
final rows =
await (database.select(database.activeSetResults)
..where((table) => table.activeWorkoutSessionId.equals(sessionId))
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
return rows.map(_activeSetResultFromRow).toList();
}
@override
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
final rows =
await (database.select(database.activeRestStates)
..where((table) => table.activeWorkoutSessionId.equals(sessionId))
..orderBy([(table) => OrderingTerm.asc(table.startedAt)]))
.get();
return rows.map(_activeRestStateFromRow).toList();
}
}
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
const DriftWorkoutHistoryRepository(this.database);
final db.AppDatabase database;
@override
Future<void> save(domain.WorkoutHistory history) async {
await database
.into(database.workoutHistories)
.insertOnConflictUpdate(_workoutHistoryCompanion(history));
for (final result in history.results) {
await saveSetResult(result);
}
}
@override
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
await database
.into(database.workoutHistorySetResults)
.insertOnConflictUpdate(_workoutHistorySetResultCompanion(result));
}
}
domain.EntityMetadata _metadataFromRow(dynamic row) {
return domain.EntityMetadata(
id: row.id as String,
createdAt: row.createdAt as DateTime,
updatedAt: row.updatedAt as DateTime,
deletedAt: row.deletedAt as DateTime?,
schemaVersion: row.schemaVersion as int,
syncState: _syncStateFromDb(row.syncState as String),
localRevision: row.localRevision as int,
originDeviceId: row.originDeviceId as String,
futureOwnerProfileId: row.futureOwnerProfileId as String?,
lastSyncedAt: row.lastSyncedAt as DateTime?,
remoteRevision: row.remoteRevision as String?,
);
}
List<Value<Object?>> _metadataValues(domain.EntityMetadata metadata) => [
Value(metadata.id),
Value(metadata.createdAt),
Value(metadata.updatedAt),
Value(metadata.deletedAt),
Value(metadata.schemaVersion),
Value(_syncStateToDb(metadata.syncState)),
Value(metadata.localRevision),
Value(metadata.originDeviceId),
Value(metadata.futureOwnerProfileId),
Value(metadata.lastSyncedAt),
Value(metadata.remoteRevision),
];
db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
final values = _metadataValues(exercise.metadata);
return db.ExercisesCompanion(
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?>,
name: Value(exercise.name),
description: Value(exercise.description),
imageMediaId: Value(exercise.imageMediaId),
videoMediaId: Value(exercise.videoMediaId),
hasTimeMeasure: Value(exercise.hasTimeMeasure),
hasRepsMeasure: Value(exercise.hasRepsMeasure),
hasScoreMeasure: Value(exercise.hasScoreMeasure),
scoreLabel: Value(exercise.scoreLabel),
scoreUnit: Value(exercise.scoreUnit),
archivedAt: Value(exercise.archivedAt),
);
}
domain.Exercise _exerciseFromRow(db.Exercise row) {
return domain.Exercise(
metadata: _metadataFromRow(row),
name: row.name,
description: row.description,
imageMediaId: row.imageMediaId,
videoMediaId: row.videoMediaId,
hasTimeMeasure: row.hasTimeMeasure,
hasRepsMeasure: row.hasRepsMeasure,
hasScoreMeasure: row.hasScoreMeasure,
scoreLabel: row.scoreLabel,
scoreUnit: row.scoreUnit,
archivedAt: row.archivedAt,
);
}
db.ProgramsCompanion _programCompanion(domain.Program program) {
final values = _metadataValues(program.metadata);
return db.ProgramsCompanion(
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?>,
name: Value(program.name),
defaultRestSeconds: Value(program.defaultRestSeconds),
);
}
domain.Program _programFromRow(
db.Program row,
List<domain.ProgramExercise> exercises,
) {
return domain.Program(
metadata: _metadataFromRow(row),
name: row.name,
defaultRestSeconds: row.defaultRestSeconds,
exercises: exercises,
);
}
db.ProgramExercisesCompanion _programExerciseCompanion(
domain.ProgramExercise exercise,
) {
final values = _metadataValues(exercise.metadata);
return db.ProgramExercisesCompanion(
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?>,
programId: Value(exercise.programId),
sourceExerciseId: Value(exercise.sourceExerciseId),
position: Value(exercise.position),
exerciseNameSnapshot: Value(exercise.exerciseNameSnapshot),
exerciseDescriptionSnapshot: Value(exercise.exerciseDescriptionSnapshot),
exerciseImageMediaIdSnapshot: Value(exercise.exerciseImageMediaIdSnapshot),
exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot),
exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot),
availableTimeSnapshot: Value(exercise.availableTimeSnapshot),
availableRepsSnapshot: Value(exercise.availableRepsSnapshot),
availableScoreSnapshot: Value(exercise.availableScoreSnapshot),
scoreLabelSnapshot: Value(exercise.scoreLabelSnapshot),
scoreUnitSnapshot: Value(exercise.scoreUnitSnapshot),
setsCount: Value(exercise.setsCount),
timeEnabled: Value(exercise.timeEnabled),
repsEnabled: Value(exercise.repsEnabled),
scoreEnabled: Value(exercise.scoreEnabled),
targetTimeSeconds: Value(exercise.targetTimeSeconds),
targetReps: Value(exercise.targetReps),
targetScore: Value(exercise.targetScore),
restSecondsOverride: Value(exercise.restSecondsOverride),
);
}
domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
return domain.ProgramExercise(
metadata: _metadataFromRow(row),
programId: row.programId,
sourceExerciseId: row.sourceExerciseId,
position: row.position,
exerciseNameSnapshot: row.exerciseNameSnapshot,
exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot,
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
availableTimeSnapshot: row.availableTimeSnapshot,
availableRepsSnapshot: row.availableRepsSnapshot,
availableScoreSnapshot: row.availableScoreSnapshot,
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
setsCount: row.setsCount,
timeEnabled: row.timeEnabled,
repsEnabled: row.repsEnabled,
scoreEnabled: row.scoreEnabled,
targetTimeSeconds: row.targetTimeSeconds,
targetReps: row.targetReps,
targetScore: row.targetScore,
restSecondsOverride: row.restSecondsOverride,
);
}
db.WorkoutTemplatesCompanion _workoutTemplateCompanion(
domain.WorkoutTemplate template,
) {
final values = _metadataValues(template.metadata);
return db.WorkoutTemplatesCompanion(
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?>,
name: Value(template.name),
lastStartedAt: Value(template.lastStartedAt),
);
}
domain.WorkoutTemplate _workoutTemplateFromRow(
db.WorkoutTemplate row,
List<domain.WorkoutTemplateProgram> programs,
List<domain.WorkoutTemplateExerciseOverride> overrides,
) {
return domain.WorkoutTemplate(
metadata: _metadataFromRow(row),
name: row.name,
lastStartedAt: row.lastStartedAt,
programs: programs,
overrides: overrides,
);
}
db.WorkoutTemplateProgramsCompanion _workoutTemplateProgramCompanion(
domain.WorkoutTemplateProgram program,
) {
final values = _metadataValues(program.metadata);
return db.WorkoutTemplateProgramsCompanion(
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?>,
workoutTemplateId: Value(program.workoutTemplateId),
sourceProgramId: Value(program.sourceProgramId),
position: Value(program.position),
programNameSnapshot: Value(program.programNameSnapshot),
defaultRestSecondsSnapshot: Value(program.defaultRestSecondsSnapshot),
programSnapshotJson: Value(program.programSnapshotJson),
);
}
domain.WorkoutTemplateProgram _workoutTemplateProgramFromRow(
db.WorkoutTemplateProgram row,
) {
return domain.WorkoutTemplateProgram(
metadata: _metadataFromRow(row),
workoutTemplateId: row.workoutTemplateId,
sourceProgramId: row.sourceProgramId,
position: row.position,
programNameSnapshot: row.programNameSnapshot,
defaultRestSecondsSnapshot: row.defaultRestSecondsSnapshot,
programSnapshotJson: row.programSnapshotJson,
);
}
db.WorkoutTemplateExerciseOverridesCompanion
_workoutTemplateExerciseOverrideCompanion(
domain.WorkoutTemplateExerciseOverride override,
) {
final values = _metadataValues(override.metadata);
return db.WorkoutTemplateExerciseOverridesCompanion(
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?>,
workoutTemplateProgramId: Value(override.workoutTemplateProgramId),
snapshotProgramExerciseId: Value(override.snapshotProgramExerciseId),
setsCountOverride: Value(override.setsCountOverride),
targetTimeSecondsOverride: Value(override.targetTimeSecondsOverride),
targetRepsOverride: Value(override.targetRepsOverride),
targetScoreOverride: Value(override.targetScoreOverride),
);
}
domain.WorkoutTemplateExerciseOverride _workoutTemplateExerciseOverrideFromRow(
db.WorkoutTemplateExerciseOverride row,
) {
return domain.WorkoutTemplateExerciseOverride(
metadata: _metadataFromRow(row),
workoutTemplateProgramId: row.workoutTemplateProgramId,
snapshotProgramExerciseId: row.snapshotProgramExerciseId,
setsCountOverride: row.setsCountOverride,
targetTimeSecondsOverride: row.targetTimeSecondsOverride,
targetRepsOverride: row.targetRepsOverride,
targetScoreOverride: row.targetScoreOverride,
);
}
db.ActiveWorkoutSessionsCompanion _activeWorkoutSessionCompanion(
domain.ActiveWorkoutSession session,
) {
final values = _metadataValues(session.metadata);
return db.ActiveWorkoutSessionsCompanion(
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?>,
sourceWorkoutTemplateId: Value(session.sourceWorkoutTemplateId),
status: Value(_activeStatusToDb(session.status)),
startedAt: Value(session.startedAt),
pausedAt: Value(session.pausedAt),
endedAt: Value(session.endedAt),
lastPersistedAt: Value(session.lastPersistedAt),
elapsedActiveMs: Value(session.elapsedActiveMs),
currentProgramIndex: Value(session.currentProgramIndex),
currentExerciseIndex: Value(session.currentExerciseIndex),
currentSetIndex: Value(session.currentSetIndex),
resolvedTemplateSnapshotJson: Value(session.resolvedTemplateSnapshotJson),
);
}
domain.ActiveWorkoutSession _activeWorkoutSessionFromRow(
db.ActiveWorkoutSession row,
) {
return domain.ActiveWorkoutSession(
metadata: _metadataFromRow(row),
sourceWorkoutTemplateId: row.sourceWorkoutTemplateId,
status: _activeStatusFromDb(row.status),
startedAt: row.startedAt,
pausedAt: row.pausedAt,
endedAt: row.endedAt,
lastPersistedAt: row.lastPersistedAt,
elapsedActiveMs: row.elapsedActiveMs,
currentProgramIndex: row.currentProgramIndex,
currentExerciseIndex: row.currentExerciseIndex,
currentSetIndex: row.currentSetIndex,
resolvedTemplateSnapshotJson: row.resolvedTemplateSnapshotJson,
);
}
db.ActiveSetResultsCompanion _activeSetResultCompanion(
domain.ActiveSetResult result,
) {
final values = _metadataValues(result.metadata);
return db.ActiveSetResultsCompanion(
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(result.activeWorkoutSessionId),
programSnapshotId: Value(result.programSnapshotId),
exerciseSnapshotId: Value(result.exerciseSnapshotId),
programIndex: Value(result.programIndex),
exerciseIndex: Value(result.exerciseIndex),
setIndex: Value(result.setIndex),
startedAt: Value(result.startedAt),
completedAt: Value(result.completedAt),
actualTimeMs: Value(result.actualTimeMs),
actualReps: Value(result.actualReps),
actualScore: Value(result.actualScore),
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
note: Value(result.note),
);
}
domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) {
return domain.ActiveSetResult(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programSnapshotId: row.programSnapshotId,
exerciseSnapshotId: row.exerciseSnapshotId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
startedAt: row.startedAt,
completedAt: row.completedAt,
actualTimeMs: row.actualTimeMs,
actualReps: row.actualReps,
actualScore: row.actualScore,
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
note: row.note,
);
}
db.ActiveRestStatesCompanion _activeRestStateCompanion(
domain.ActiveRestState restState,
) {
final values = _metadataValues(restState.metadata);
return db.ActiveRestStatesCompanion(
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(restState.activeWorkoutSessionId),
afterProgramIndex: Value(restState.afterProgramIndex),
afterExerciseIndex: Value(restState.afterExerciseIndex),
afterSetIndex: Value(restState.afterSetIndex),
plannedRestSeconds: Value(restState.plannedRestSeconds),
adjustedRestSeconds: Value(restState.adjustedRestSeconds),
startedAt: Value(restState.startedAt),
endedAt: Value(restState.endedAt),
skippedAt: Value(restState.skippedAt),
);
}
domain.ActiveRestState _activeRestStateFromRow(db.ActiveRestState row) {
return domain.ActiveRestState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
afterProgramIndex: row.afterProgramIndex,
afterExerciseIndex: row.afterExerciseIndex,
afterSetIndex: row.afterSetIndex,
plannedRestSeconds: row.plannedRestSeconds,
adjustedRestSeconds: row.adjustedRestSeconds,
startedAt: row.startedAt,
endedAt: row.endedAt,
skippedAt: row.skippedAt,
);
}
db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
domain.WorkoutHistory history,
) {
final values = _metadataValues(history.metadata);
return db.WorkoutHistoriesCompanion(
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?>,
sourceWorkoutTemplateId: Value(history.sourceWorkoutTemplateId),
sourceActiveWorkoutSessionId: Value(history.sourceActiveWorkoutSessionId),
nameSnapshot: Value(history.nameSnapshot),
startedAt: Value(history.startedAt),
endedAt: Value(history.endedAt),
totalActiveMs: Value(history.totalActiveMs),
completed: Value(history.completed),
historySnapshotJson: Value(history.historySnapshotJson),
);
}
db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
domain.WorkoutHistorySetResult result,
) {
final values = _metadataValues(result.metadata);
return db.WorkoutHistorySetResultsCompanion(
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?>,
workoutHistoryId: Value(result.workoutHistoryId),
programSnapshotId: Value(result.programSnapshotId),
exerciseSnapshotId: Value(result.exerciseSnapshotId),
programIndex: Value(result.programIndex),
exerciseIndex: Value(result.exerciseIndex),
setIndex: Value(result.setIndex),
programNameSnapshot: Value(result.programNameSnapshot),
exerciseNameSnapshot: Value(result.exerciseNameSnapshot),
timeEnabledSnapshot: Value(result.timeEnabledSnapshot),
repsEnabledSnapshot: Value(result.repsEnabledSnapshot),
scoreEnabledSnapshot: Value(result.scoreEnabledSnapshot),
targetTimeSecondsSnapshot: Value(result.targetTimeSecondsSnapshot),
targetRepsSnapshot: Value(result.targetRepsSnapshot),
targetScoreSnapshot: Value(result.targetScoreSnapshot),
actualTimeMs: Value(result.actualTimeMs),
actualReps: Value(result.actualReps),
actualScore: Value(result.actualScore),
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
startedAt: Value(result.startedAt),
completedAt: Value(result.completedAt),
);
}
String _syncStateToDb(domain.SyncState state) => switch (state) {
domain.SyncState.localOnly => 'localOnly',
domain.SyncState.dirty => 'dirty',
domain.SyncState.synced => 'synced',
domain.SyncState.deleted => 'deleted',
};
domain.SyncState _syncStateFromDb(String value) => switch (value) {
'localOnly' => domain.SyncState.localOnly,
'dirty' => domain.SyncState.dirty,
'synced' => domain.SyncState.synced,
'deleted' => domain.SyncState.deleted,
_ => throw domain.DomainException('Unknown sync state: $value'),
};
String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) {
domain.ActiveWorkoutStatus.running => 'running',
domain.ActiveWorkoutStatus.paused => 'paused',
domain.ActiveWorkoutStatus.savedExit => 'savedExit',
domain.ActiveWorkoutStatus.completed => 'completed',
domain.ActiveWorkoutStatus.abandoned => 'abandoned',
};
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
'running' => domain.ActiveWorkoutStatus.running,
'paused' => domain.ActiveWorkoutStatus.paused,
'savedExit' => domain.ActiveWorkoutStatus.savedExit,
'completed' => domain.ActiveWorkoutStatus.completed,
'abandoned' => domain.ActiveWorkoutStatus.abandoned,
_ => throw domain.DomainException('Unknown active workout status: $value'),
};

View File

@ -1,2 +1,3 @@
export 'app_database.dart';
export 'drift_repositories.dart';
export 'local_id.dart';

View File

@ -1,12 +1,17 @@
import 'dart:math';
import '../../application/ports.dart';
const _crockfordBase32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
final class LocalIdGenerator {
final class LocalIdGenerator implements IdGenerator {
LocalIdGenerator({Random? random}) : _random = random ?? Random.secure();
final Random _random;
@override
String newId() => newUlid();
String newUlid({DateTime? now}) {
final timestamp = (now ?? DateTime.now().toUtc()).millisecondsSinceEpoch;
final buffer = StringBuffer();

View File

@ -0,0 +1,117 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
void main() {
test('Exercise requires at least one active measure', () {
expect(
() => Exercise(
metadata: _metadata('exercise-1'),
name: 'Push up',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: false,
),
throwsA(isA<DomainException>()),
);
});
test('Workout template use case rejects active measure overrides', () async {
final useCase = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.overrideExerciseTargets(
workoutTemplateProgramId: 'template-program-1',
snapshotProgramExerciseId: 'exercise-snapshot-1',
attemptedMeasureOverride: {WorkoutMeasure.reps},
),
throwsA(isA<DomainException>()),
);
});
test('Active session elapsed time is based on persisted timestamps', () {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.running,
startedAt: clock.now(),
lastPersistedAt: clock.now(),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: '{}',
);
clock.value = clock.value.add(const Duration(seconds: 30));
final paused = session.pause(clock.now());
expect(paused.elapsedActiveMs, 30000);
clock.value = clock.value.add(const Duration(minutes: 5));
final resumed = paused.resume(clock.now());
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 30000);
clock.value = clock.value.add(const Duration(seconds: 15));
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
originDeviceId: 'device-1',
);
}
final class _FakeClock implements Clock {
_FakeClock(this.value);
DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<Program?> findById(String id) async => null;
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}