diff --git a/lib/application/application.dart b/lib/application/application.dart index 1ceb379..32e79f7 100644 --- a/lib/application/application.dart +++ b/lib/application/application.dart @@ -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'; diff --git a/lib/application/ports.dart b/lib/application/ports.dart new file mode 100644 index 0000000..f83ebab --- /dev/null +++ b/lib/application/ports.dart @@ -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 findById(String id); + Future> listActive(); + Future save(Exercise exercise); +} + +abstract interface class ProgramRepository { + Future findById(String id); + Future save(Program program); + Future saveExercise(ProgramExercise exercise); +} + +abstract interface class WorkoutTemplateRepository { + Future findById(String id); + Future save(WorkoutTemplate template); + Future saveProgram(WorkoutTemplateProgram program); + Future saveOverride(WorkoutTemplateExerciseOverride override); +} + +abstract interface class ActiveSessionRepository { + Future findById(String id); + Future save(ActiveWorkoutSession session); + Future saveSetResult(ActiveSetResult result); + Future saveRestState(ActiveRestState restState); + Future> listSetResults(String sessionId); + Future> listRestStates(String sessionId); +} + +abstract interface class WorkoutHistoryRepository { + Future save(WorkoutHistory history); + Future saveSetResult(WorkoutHistorySetResult result); +} diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart new file mode 100644 index 0000000..d43c94c --- /dev/null +++ b/lib/application/use_cases.dart @@ -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 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 save(Exercise exercise) => repository.save(exercise); + + Future 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 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 addExercise({ + required String programId, + required String exerciseId, + required int position, + required int setsCount, + required Set 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 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 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 overrideExerciseTargets({ + required String workoutTemplateProgramId, + required String snapshotProgramExerciseId, + int? setsCountOverride, + int? targetTimeSecondsOverride, + int? targetRepsOverride, + double? targetScoreOverride, + Set? 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 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 pause(String sessionId) async { + final session = await _requiredSession(sessionId); + final paused = session.pause(clock.now()); + await sessionRepository.save(paused); + return paused; + } + + Future 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 recordSetResult(ActiveSetResult result) async { + await sessionRepository.saveSetResult(result); + return result; + } + + Future 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 _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 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, + ); +} diff --git a/lib/domain/domain.dart b/lib/domain/domain.dart index dbb123d..173f4bd 100644 --- a/lib/domain/domain.dart +++ b/lib/domain/domain.dart @@ -2,3 +2,5 @@ /// /// Keep this layer free from Flutter, Drift, platform APIs, and IO. library; + +export 'entities.dart'; diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart new file mode 100644 index 0000000..5281155 --- /dev/null +++ b/lib/domain/entities.dart @@ -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 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 exercises; + + Program copyWith({ + List? 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 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 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 programs; + final List 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 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.'); + } +} diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart new file mode 100644 index 0000000..35e212d --- /dev/null +++ b/lib/infrastructure/local/drift_repositories.dart @@ -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 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> 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 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 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 save(domain.Program program) async { + await database + .into(database.programs) + .insertOnConflictUpdate(_programCompanion(program)); + for (final exercise in program.exercises) { + await saveExercise(exercise); + } + } + + @override + Future 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 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 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 saveProgram(domain.WorkoutTemplateProgram program) async { + await database + .into(database.workoutTemplatePrograms) + .insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)); + } + + @override + Future 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 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 save(domain.ActiveWorkoutSession session) async { + await database + .into(database.activeWorkoutSessions) + .insertOnConflictUpdate(_activeWorkoutSessionCompanion(session)); + } + + @override + Future saveSetResult(domain.ActiveSetResult result) async { + await database + .into(database.activeSetResults) + .insertOnConflictUpdate(_activeSetResultCompanion(result)); + } + + @override + Future saveRestState(domain.ActiveRestState restState) async { + await database + .into(database.activeRestStates) + .insertOnConflictUpdate(_activeRestStateCompanion(restState)); + } + + @override + Future> 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> 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 save(domain.WorkoutHistory history) async { + await database + .into(database.workoutHistories) + .insertOnConflictUpdate(_workoutHistoryCompanion(history)); + for (final result in history.results) { + await saveSetResult(result); + } + } + + @override + Future 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> _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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + name: Value(program.name), + defaultRestSeconds: Value(program.defaultRestSeconds), + ); +} + +domain.Program _programFromRow( + db.Program row, + List 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + name: Value(template.name), + lastStartedAt: Value(template.lastStartedAt), + ); +} + +domain.WorkoutTemplate _workoutTemplateFromRow( + db.WorkoutTemplate row, + List programs, + List 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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'), +}; diff --git a/lib/infrastructure/local/local.dart b/lib/infrastructure/local/local.dart index c44744f..54d2676 100644 --- a/lib/infrastructure/local/local.dart +++ b/lib/infrastructure/local/local.dart @@ -1,2 +1,3 @@ export 'app_database.dart'; +export 'drift_repositories.dart'; export 'local_id.dart'; diff --git a/lib/infrastructure/local/local_id.dart b/lib/infrastructure/local/local_id.dart index 3d5702b..26242d7 100644 --- a/lib/infrastructure/local/local_id.dart +++ b/lib/infrastructure/local/local_id.dart @@ -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(); diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart new file mode 100644 index 0000000..603764b --- /dev/null +++ b/test/application/use_cases_test.dart @@ -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()), + ); + }); + + 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()), + ); + }); + + 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 findById(String id) async => null; + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} + +final class _FakeProgramRepository implements ProgramRepository { + @override + Future findById(String id) async => null; + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +}