import 'dart:convert'; enum SyncState { localOnly, dirty, synced, deleted } enum MediaKind { image, video } enum WorkoutMeasure { time, reps, score } enum ScoreInputMode { manual, stopwatch } enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum SetResultStatus { completed, skipped } enum ActiveScoreStopwatchStatus { running, stopped } final class DomainException implements Exception { const DomainException(this.message); 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, String? imageMediaId, List imageMediaIds = const [], String? iconMediaId, this.videoMediaId, required this.hasTimeMeasure, required this.hasRepsMeasure, required this.hasScoreMeasure, this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, this.scoreUnit, this.defaultTargetTimeSeconds, this.defaultTargetReps, this.defaultTargetScore, this.defaultTargetScoreTimeMs, this.archivedAt, }) : name = _nonBlank(name, 'Exercise name'), imageMediaIds = _resolvedExerciseImageMediaIds( imageMediaId, imageMediaIds, ), iconMediaId = _validatedIconMediaId( iconMediaId, _resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds), ) { _requireAtLeastOneMeasure( hasTime: hasTimeMeasure, hasReps: hasRepsMeasure, hasScore: hasScoreMeasure, ); if (!hasScoreMeasure && scoreInputMode == ScoreInputMode.stopwatch) { throw const DomainException( 'Stopwatch score mode requires an active score measure.', ); } if (hasScoreMeasure && scoreInputMode == ScoreInputMode.manual) { _nonBlank(scoreLabel, 'Score label'); _nonBlank(scoreUnit, 'Score unit'); } _requireNullablePositive( defaultTargetTimeSeconds, 'Default target time seconds', ); _requireNullablePositive(defaultTargetReps, 'Default target reps'); _requireNullableNonNegativeDouble( defaultTargetScore, 'Default target score', ); _requireNullablePositive( defaultTargetScoreTimeMs, 'Default target score time ms', ); } final EntityMetadata metadata; final String name; final String? description; final List imageMediaIds; final String? iconMediaId; final String? videoMediaId; final bool hasTimeMeasure; final bool hasRepsMeasure; final bool hasScoreMeasure; final ScoreInputMode scoreInputMode; final String? scoreLabel; final String? scoreUnit; final int? defaultTargetTimeSeconds; final int? defaultTargetReps; final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; final DateTime? archivedAt; Set get availableMeasures => { if (hasTimeMeasure) WorkoutMeasure.time, if (hasRepsMeasure) WorkoutMeasure.reps, if (hasScoreMeasure) WorkoutMeasure.score, }; String? get imageMediaId => imageMediaIds.isEmpty ? null : imageMediaIds.first; Exercise archive(DateTime now) { return copyWith(archivedAt: now, metadata: metadata.touch(now)); } Exercise copyWith({ EntityMetadata? metadata, String? name, Object? description = _unchanged, Object? imageMediaId = _unchanged, Object? imageMediaIds = _unchanged, Object? iconMediaId = _unchanged, Object? videoMediaId = _unchanged, bool? hasTimeMeasure, bool? hasRepsMeasure, bool? hasScoreMeasure, ScoreInputMode? scoreInputMode, Object? scoreLabel = _unchanged, Object? scoreUnit = _unchanged, Object? defaultTargetTimeSeconds = _unchanged, Object? defaultTargetReps = _unchanged, Object? defaultTargetScore = _unchanged, Object? defaultTargetScoreTimeMs = _unchanged, Object? archivedAt = _unchanged, }) { final resolvedImageMediaIds = imageMediaIds == _unchanged ? imageMediaId == _unchanged ? this.imageMediaIds : [if (imageMediaId != null) imageMediaId as String] : imageMediaIds as List; final resolvedIconMediaId = iconMediaId == _unchanged ? this.iconMediaId != null && resolvedImageMediaIds.contains(this.iconMediaId) ? this.iconMediaId : null : iconMediaId as String?; return Exercise( metadata: metadata ?? this.metadata, name: name ?? this.name, description: description == _unchanged ? this.description : description as String?, imageMediaIds: resolvedImageMediaIds, iconMediaId: resolvedIconMediaId, videoMediaId: videoMediaId == _unchanged ? this.videoMediaId : videoMediaId as String?, hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure, hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure, hasScoreMeasure: hasScoreMeasure ?? this.hasScoreMeasure, scoreInputMode: scoreInputMode ?? this.scoreInputMode, scoreLabel: scoreLabel == _unchanged ? this.scoreLabel : scoreLabel as String?, scoreUnit: scoreUnit == _unchanged ? this.scoreUnit : scoreUnit as String?, defaultTargetTimeSeconds: defaultTargetTimeSeconds == _unchanged ? this.defaultTargetTimeSeconds : defaultTargetTimeSeconds as int?, defaultTargetReps: defaultTargetReps == _unchanged ? this.defaultTargetReps : defaultTargetReps as int?, defaultTargetScore: defaultTargetScore == _unchanged ? this.defaultTargetScore : defaultTargetScore as double?, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged ? this.defaultTargetScoreTimeMs : defaultTargetScoreTimeMs as int?, archivedAt: archivedAt == _unchanged ? this.archivedAt : archivedAt as DateTime?, ); } } const Object _unchanged = Object(); 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, String? name, int? defaultRestSeconds, }) { return Program( metadata: metadata ?? this.metadata, name: name ?? this.name, defaultRestSeconds: defaultRestSeconds ?? this.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, List exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, required this.availableRepsSnapshot, required this.availableScoreSnapshot, this.scoreInputModeSnapshot = ScoreInputMode.manual, this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.setsCount, required this.timeEnabled, required this.repsEnabled, required this.scoreEnabled, this.targetTimeSeconds, this.targetReps, this.targetScore, this.targetScoreTimeMs, this.restSecondsOverride, }) : exerciseNameSnapshot = _nonBlank( exerciseNameSnapshot, 'Exercise snapshot name', ), exerciseImageMediaIdsSnapshot = _validatedImageMediaIds( exerciseImageMediaIdsSnapshot.isEmpty && exerciseImageMediaIdSnapshot != null ? [exerciseImageMediaIdSnapshot] : exerciseImageMediaIdsSnapshot, ) { _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'); _requireNullablePositive(targetScoreTimeMs, 'Target score time ms'); _requireNullableNonNegative(restSecondsOverride, 'Rest seconds override'); _requireScoreTargetShape( scoreEnabled: scoreEnabled, scoreInputMode: scoreInputModeSnapshot, targetScore: targetScore, targetScoreTimeMs: targetScoreTimeMs, ); } final EntityMetadata metadata; final String programId; final String? sourceExerciseId; final int position; final String exerciseNameSnapshot; final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; final List exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; final bool availableRepsSnapshot; final bool availableScoreSnapshot; final ScoreInputMode scoreInputModeSnapshot; 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? targetScoreTimeMs; 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? targetScoreTimeMs, 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, exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseArchivedSnapshot: exercise.archivedAt != null, availableTimeSnapshot: exercise.hasTimeMeasure, availableRepsSnapshot: exercise.hasRepsMeasure, availableScoreSnapshot: exercise.hasScoreMeasure, scoreInputModeSnapshot: exercise.scoreInputMode, 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 ?? exercise.defaultTargetTimeSeconds, targetReps: targetReps ?? exercise.defaultTargetReps, targetScore: targetScore ?? exercise.defaultTargetScore, targetScoreTimeMs: targetScoreTimeMs ?? exercise.defaultTargetScoreTimeMs, restSecondsOverride: restSecondsOverride, ); } Map toSnapshotJson() => { 'id': metadata.id, 'sourceExerciseId': sourceExerciseId, 'position': position, 'exerciseNameSnapshot': exerciseNameSnapshot, 'exerciseDescriptionSnapshot': exerciseDescriptionSnapshot, 'exerciseImageMediaIdSnapshot': exerciseImageMediaIdSnapshot, 'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, 'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, 'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot, 'exerciseArchivedSnapshot': exerciseArchivedSnapshot, 'availableTimeSnapshot': availableTimeSnapshot, 'availableRepsSnapshot': availableRepsSnapshot, 'availableScoreSnapshot': availableScoreSnapshot, 'scoreInputModeSnapshot': scoreInputModeSnapshot.name, 'scoreLabelSnapshot': scoreLabelSnapshot, 'scoreUnitSnapshot': scoreUnitSnapshot, 'setsCount': setsCount, 'timeEnabled': timeEnabled, 'repsEnabled': repsEnabled, 'scoreEnabled': scoreEnabled, 'targetTimeSeconds': targetTimeSeconds, 'targetReps': targetReps, 'targetScore': targetScore, 'targetScoreTimeMs': targetScoreTimeMs, '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, this.targetScoreTimeMsOverride, }) : 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', ); _requireNullablePositive( targetScoreTimeMsOverride, 'Target score time ms override', ); if (targetScoreOverride != null && targetScoreTimeMsOverride != null) { throw const DomainException( 'Manual and stopwatch score overrides cannot both be set.', ); } } final EntityMetadata metadata; final String workoutTemplateProgramId; final String snapshotProgramExerciseId; final int? setsCountOverride; final int? targetTimeSecondsOverride; final int? targetRepsOverride; final double? targetScoreOverride; final int? targetScoreTimeMsOverride; } 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 { 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.actualScoreTimeMs, this.scoreInputModeSnapshot = ScoreInputMode.manual, this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.note, this.status = SetResultStatus.completed, }) { if (status == SetResultStatus.skipped && (actualTimeMs != null || actualReps != null || actualScore != null || actualScoreTimeMs != null)) { throw const DomainException('Skipped set results cannot have values.'); } _requireScoreResultShape( scoreInputMode: scoreInputModeSnapshot, actualScore: actualScore, actualScoreTimeMs: actualScoreTimeMs, ); } 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 int? actualScoreTimeMs; final ScoreInputMode scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final String? note; final SetResultStatus status; } final class ActiveScoreStopwatchState { ActiveScoreStopwatchState({ required this.metadata, required this.activeWorkoutSessionId, required this.programIndex, required this.exerciseIndex, required this.setIndex, required this.status, required this.startedAt, required this.accumulatedMs, this.stoppedAt, }) { _requireNonNegative(programIndex, 'Program index'); _requireNonNegative(exerciseIndex, 'Exercise index'); _requireNonNegative(setIndex, 'Set index'); _requireNonNegative(accumulatedMs, 'Accumulated milliseconds'); } final EntityMetadata metadata; final String activeWorkoutSessionId; final int programIndex; final int exerciseIndex; final int setIndex; final ActiveScoreStopwatchStatus status; final DateTime startedAt; final int accumulatedMs; final DateTime? stoppedAt; int elapsedMillisecondsAt(DateTime now) { if (status == ActiveScoreStopwatchStatus.stopped) { return accumulatedMs; } return accumulatedMs + now.difference(startedAt).inMilliseconds; } ActiveScoreStopwatchState copyWith({ EntityMetadata? metadata, ActiveScoreStopwatchStatus? status, DateTime? startedAt, int? accumulatedMs, Object? stoppedAt = _unchanged, }) { return ActiveScoreStopwatchState( metadata: metadata ?? this.metadata, activeWorkoutSessionId: activeWorkoutSessionId, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, status: status ?? this.status, startedAt: startedAt ?? this.startedAt, accumulatedMs: accumulatedMs ?? this.accumulatedMs, stoppedAt: stoppedAt == _unchanged ? this.stoppedAt : stoppedAt as DateTime?, ); } } final class ActiveRestState { const ActiveRestState({ required this.metadata, 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; ActiveRestState copyWith({ EntityMetadata? metadata, int? adjustedRestSeconds, Object? endedAt = _unchanged, Object? skippedAt = _unchanged, }) { return ActiveRestState( metadata: metadata ?? this.metadata, activeWorkoutSessionId: activeWorkoutSessionId, afterProgramIndex: afterProgramIndex, afterExerciseIndex: afterExerciseIndex, afterSetIndex: afterSetIndex, plannedRestSeconds: plannedRestSeconds, adjustedRestSeconds: adjustedRestSeconds ?? this.adjustedRestSeconds, startedAt: startedAt, endedAt: endedAt == _unchanged ? this.endedAt : endedAt as DateTime?, skippedAt: skippedAt == _unchanged ? this.skippedAt : skippedAt as DateTime?, ); } } 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 { 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.targetScoreTimeMsSnapshot, this.actualTimeMs, this.actualReps, this.actualScore, this.actualScoreTimeMs, this.scoreInputModeSnapshot = ScoreInputMode.manual, this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.startedAt, this.completedAt, this.status = SetResultStatus.completed, }) { if (status == SetResultStatus.skipped && (actualTimeMs != null || actualReps != null || actualScore != null || actualScoreTimeMs != null)) { throw const DomainException( 'Skipped history results cannot have values.', ); } _requireScoreTargetShape( scoreEnabled: scoreEnabledSnapshot, scoreInputMode: scoreInputModeSnapshot, targetScore: targetScoreSnapshot, targetScoreTimeMs: targetScoreTimeMsSnapshot, ); _requireScoreResultShape( scoreInputMode: scoreInputModeSnapshot, actualScore: actualScore, actualScoreTimeMs: actualScoreTimeMs, ); } final EntityMetadata metadata; 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? targetScoreTimeMsSnapshot; final int? actualTimeMs; final int? actualReps; final double? actualScore; final int? actualScoreTimeMs; final ScoreInputMode scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final DateTime? startedAt; final DateTime? completedAt; final SetResultStatus status; } String _nonBlank(String? value, String label) { final trimmed = value?.trim(); if (trimmed == null || trimmed.isEmpty) { throw DomainException('$label must not be blank.'); } return trimmed; } List _validatedImageMediaIds(List ids) { if (ids.length > 5) { throw const DomainException('An exercise cannot have more than 5 images.'); } final normalized = ids .map((id) => _nonBlank(id, 'Image media id')) .toList(growable: false); if (normalized.toSet().length != normalized.length) { throw const DomainException('Exercise images must be unique.'); } return normalized; } List _resolvedExerciseImageMediaIds( String? imageMediaId, List imageMediaIds, ) { return _validatedImageMediaIds( imageMediaIds.isEmpty && imageMediaId != null ? [imageMediaId] : imageMediaIds, ); } String? _validatedIconMediaId(String? iconMediaId, List imageMediaIds) { if (iconMediaId == null) { return null; } final normalized = _nonBlank(iconMediaId, 'Icon media id'); if (!imageMediaIds.contains(normalized)) { throw const DomainException( 'Exercise icon must be one of the exercise images.', ); } return normalized; } 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.'); } } void _requireScoreTargetShape({ required bool scoreEnabled, required ScoreInputMode scoreInputMode, required double? targetScore, required int? targetScoreTimeMs, }) { if (targetScore != null && targetScoreTimeMs != null) { throw const DomainException( 'Manual and stopwatch score targets cannot both be set.', ); } if (targetScore != null && (!scoreEnabled || scoreInputMode != ScoreInputMode.manual)) { throw const DomainException( 'Manual score target requires manual score mode.', ); } if (targetScoreTimeMs != null && (!scoreEnabled || scoreInputMode != ScoreInputMode.stopwatch)) { throw const DomainException( 'Stopwatch score target requires stopwatch score mode.', ); } } void _requireScoreResultShape({ required ScoreInputMode scoreInputMode, required double? actualScore, required int? actualScoreTimeMs, }) { if (actualScore != null && actualScoreTimeMs != null) { throw const DomainException( 'Manual and stopwatch score results cannot both be set.', ); } if (actualScore != null && scoreInputMode != ScoreInputMode.manual) { throw const DomainException( 'Manual score result requires manual score mode.', ); } if (actualScoreTimeMs != null && scoreInputMode != ScoreInputMode.stopwatch) { throw const DomainException( 'Stopwatch score result requires stopwatch score mode.', ); } }