import 'dart:convert'; enum SyncState { localOnly, dirty, synced, deleted } enum MediaKind { image, video } enum WorkoutMeasure { time, reps, score } enum ScoreInputMode { manual, stopwatch } enum ExerciseStepType { time, reps } enum ExerciseCategory { shoot, freeThrows, dribble, finishing, conditioning, defense, mobility, uncategorized, } enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum SetResultStatus { completed, skipped } enum ActiveSetTimerStatus { running, paused, stopped, skipped } enum ActiveScoreStopwatchStatus { running, paused, stopped } enum ShareResourceType { program, workoutTemplate } enum ShareInboxStatus { pending, accepted, declined, revoked } enum PendingShareActionType { send, accept, decline, revoke } enum PendingShareActionStatus { pending, succeeded, failed } enum ActiveExerciseStepProgressStatus { notStarted, waitingManual, runningTimer, pausedTimer, stoppedTimer, sequenceComplete, } 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 UserAccountSession { UserAccountSession({ required String id, required String serverUserId, required String email, this.displayName, required this.isLoggedIn, required this.createdAt, required this.updatedAt, this.lastAuthenticatedAt, }) : id = _nonBlank(id, 'User account session id'), serverUserId = _nonBlank(serverUserId, 'Server user id'), email = _nonBlank(email, 'Email') { if (displayName != null && displayName!.trim().isEmpty) { throw const DomainException('Display name must not be blank.'); } } final String id; final String serverUserId; final String email; final String? displayName; final bool isLoggedIn; final DateTime createdAt; final DateTime updatedAt; final DateTime? lastAuthenticatedAt; UserAccountSession copyWith({ String? serverUserId, String? email, Object? displayName = _unchanged, bool? isLoggedIn, DateTime? updatedAt, Object? lastAuthenticatedAt = _unchanged, }) { return UserAccountSession( id: id, serverUserId: serverUserId ?? this.serverUserId, email: email ?? this.email, displayName: displayName == _unchanged ? this.displayName : displayName as String?, isLoggedIn: isLoggedIn ?? this.isLoggedIn, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, lastAuthenticatedAt: lastAuthenticatedAt == _unchanged ? this.lastAuthenticatedAt : lastAuthenticatedAt as DateTime?, ); } } final class ShareInboxItem { ShareInboxItem({ required String shareId, required String senderUserId, required this.resourceType, required String payloadJson, required this.status, required this.createdAt, this.respondedAt, }) : shareId = _nonBlank(shareId, 'Share id'), senderUserId = _nonBlank(senderUserId, 'Sender user id'), payloadJson = _nonBlank(payloadJson, 'Share payload JSON'); final String shareId; final String senderUserId; final ShareResourceType resourceType; final String payloadJson; final ShareInboxStatus status; final DateTime createdAt; final DateTime? respondedAt; ShareInboxItem copyWith({ String? senderUserId, ShareResourceType? resourceType, String? payloadJson, ShareInboxStatus? status, DateTime? createdAt, Object? respondedAt = _unchanged, }) { return ShareInboxItem( shareId: shareId, senderUserId: senderUserId ?? this.senderUserId, resourceType: resourceType ?? this.resourceType, payloadJson: payloadJson ?? this.payloadJson, status: status ?? this.status, createdAt: createdAt ?? this.createdAt, respondedAt: respondedAt == _unchanged ? this.respondedAt : respondedAt as DateTime?, ); } } final class PendingShareAction { PendingShareAction({ required String id, required this.actionType, this.shareId, this.resourceType, this.payloadJson, this.recipientEmailsJson, required this.createdAt, this.lastAttemptAt, this.attemptCount = 0, this.status = PendingShareActionStatus.pending, }) : id = _nonBlank(id, 'Pending share action id') { _requireNonNegative(attemptCount, 'Share action attempt count'); if (shareId != null) { _nonBlank(shareId, 'Share id'); } if (payloadJson != null) { _nonBlank(payloadJson, 'Share payload JSON'); } if (recipientEmailsJson != null) { _nonBlank(recipientEmailsJson, 'Recipient emails JSON'); } } final String id; final PendingShareActionType actionType; final String? shareId; final ShareResourceType? resourceType; final String? payloadJson; final String? recipientEmailsJson; final DateTime createdAt; final DateTime? lastAttemptAt; final int attemptCount; final PendingShareActionStatus status; PendingShareAction copyWith({ Object? shareId = _unchanged, ShareResourceType? resourceType, Object? payloadJson = _unchanged, Object? recipientEmailsJson = _unchanged, Object? lastAttemptAt = _unchanged, int? attemptCount, PendingShareActionStatus? status, }) { return PendingShareAction( id: id, actionType: actionType, shareId: shareId == _unchanged ? this.shareId : shareId as String?, resourceType: resourceType ?? this.resourceType, payloadJson: payloadJson == _unchanged ? this.payloadJson : payloadJson as String?, recipientEmailsJson: recipientEmailsJson == _unchanged ? this.recipientEmailsJson : recipientEmailsJson as String?, createdAt: createdAt, lastAttemptAt: lastAttemptAt == _unchanged ? this.lastAttemptAt : lastAttemptAt as DateTime?, attemptCount: attemptCount ?? this.attemptCount, status: status ?? this.status, ); } } 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.autoStartNextTimedStep = true, this.category = ExerciseCategory.uncategorized, this.isExample = false, List tags = const [], List steps = const [], this.archivedAt, }) : name = _nonBlank(name, 'Exercise name'), tags = normalizeTags(tags), imageMediaIds = _resolvedExerciseImageMediaIds( imageMediaId, imageMediaIds, ), iconMediaId = _validatedIconMediaId( iconMediaId, _resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds), ), steps = _validatedExerciseSteps(steps) { _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', ); _requireLinkedStepSeriesScoreShape( steps: steps, scoreEnabled: hasScoreMeasure, scoreInputMode: scoreInputMode, ); } 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 bool autoStartNextTimedStep; final ExerciseCategory category; final bool isExample; final List tags; final List steps; 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, bool? autoStartNextTimedStep, ExerciseCategory? category, bool? isExample, Object? tags = _unchanged, Object? steps = _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?, autoStartNextTimedStep: autoStartNextTimedStep ?? this.autoStartNextTimedStep, category: category ?? this.category, isExample: isExample ?? this.isExample, tags: tags == _unchanged ? this.tags : tags as List, steps: steps == _unchanged ? this.steps : steps as List, archivedAt: archivedAt == _unchanged ? this.archivedAt : archivedAt as DateTime?, ); } } final class ExerciseStep { ExerciseStep({ required String id, required this.position, required String name, required this.type, required this.defaultTargetValue, this.hasScore = false, this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, this.scoreUnit, this.defaultTargetScore, this.defaultTargetScoreTimeMs, this.linkedToSeriesScore = false, }) : id = _nonBlank(id, 'Exercise step id'), name = _nonBlank(name, 'Exercise step name') { _requireNonNegative(position, 'Exercise step position'); _requirePositive(defaultTargetValue, 'Exercise step default target value'); _validateExerciseStepScoreShape( hasScore: hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, linkedToSeriesScore: linkedToSeriesScore, ); } final String id; final int position; final String name; final ExerciseStepType type; final int defaultTargetValue; final bool hasScore; final ScoreInputMode scoreInputMode; final String? scoreLabel; final String? scoreUnit; final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; final bool linkedToSeriesScore; Map toSnapshotJson() => { 'id': id, 'position': position, 'name': name, 'type': type.name, 'defaultTargetValue': defaultTargetValue, 'hasScore': hasScore, 'scoreInputMode': scoreInputMode.name, 'scoreLabel': scoreLabel, 'scoreUnit': scoreUnit, 'defaultTargetScore': defaultTargetScore, 'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs, 'linkedToSeriesScore': linkedToSeriesScore, }; } const Object _unchanged = Object(); final class Program { Program({ required this.metadata, required String name, required this.defaultRestSeconds, this.isExample = false, List tags = const [], this.exercises = const [], }) : name = _nonBlank(name, 'Program name'), tags = normalizeTags(tags) { _requireNonNegative(defaultRestSeconds, 'Default rest seconds'); } final EntityMetadata metadata; final String name; final int defaultRestSeconds; final bool isExample; final List tags; final List exercises; Program copyWith({ List? exercises, EntityMetadata? metadata, String? name, int? defaultRestSeconds, bool? isExample, Object? tags = _unchanged, }) { return Program( metadata: metadata ?? this.metadata, name: name ?? this.name, defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds, isExample: isExample ?? this.isExample, tags: tags == _unchanged ? this.tags : tags as List, 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, List exerciseStepsSnapshot = const [], this.autoStartNextTimedStepSnapshot = true, this.autoStartNextTimedStepOverride, 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, ), exerciseStepsSnapshot = _validatedExerciseSteps(exerciseStepsSnapshot) { _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, ); _requireLinkedStepSeriesScoreShape( steps: exerciseStepsSnapshot, scoreEnabled: scoreEnabled, scoreInputMode: scoreInputModeSnapshot, ); } 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 List exerciseStepsSnapshot; final bool autoStartNextTimedStepSnapshot; final bool? autoStartNextTimedStepOverride; 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, exerciseStepsSnapshot: exercise.steps, autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStep, 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, 'exerciseStepsSnapshot': exerciseStepsSnapshot .map((step) => step.toSnapshotJson()) .toList(), 'autoStartNextTimedStepSnapshot': autoStartNextTimedStepSnapshot, 'autoStartNextTimedStepOverride': autoStartNextTimedStepOverride, '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.isExample = false, List tags = const [], this.programs = const [], this.overrides = const [], }) : name = _nonBlank(name, 'Workout template name'), tags = normalizeTags(tags); final EntityMetadata metadata; final String name; final DateTime? lastStartedAt; final bool isExample; final List tags; 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, this.autoStartNextTimedStepOverride, }) : 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 bool? autoStartNextTimedStepOverride; } 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.running) { 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 ActiveManualScoreState { ActiveManualScoreState({ required this.metadata, required this.activeWorkoutSessionId, required this.programIndex, required this.exerciseIndex, required this.setIndex, required this.value, required this.updatedAt, }) { _requireNonNegative(programIndex, 'Program index'); _requireNonNegative(exerciseIndex, 'Exercise index'); _requireNonNegative(setIndex, 'Set index'); _requireNullableNonNegativeDouble(value, 'Manual score value'); } final EntityMetadata metadata; final String activeWorkoutSessionId; final int programIndex; final int exerciseIndex; final int setIndex; final double value; final DateTime updatedAt; ActiveManualScoreState copyWith({ EntityMetadata? metadata, double? value, DateTime? updatedAt, }) { return ActiveManualScoreState( metadata: metadata ?? this.metadata, activeWorkoutSessionId: activeWorkoutSessionId, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, value: value ?? this.value, updatedAt: updatedAt ?? this.updatedAt, ); } } final class ActiveSetTimerState { ActiveSetTimerState({ required this.metadata, required this.activeWorkoutSessionId, required this.programIndex, required this.exerciseIndex, required this.setIndex, required this.status, this.startedAt, required this.accumulatedMs, this.stoppedAt, this.skippedAt, }) { _requireNonNegative(programIndex, 'Program index'); _requireNonNegative(exerciseIndex, 'Exercise index'); _requireNonNegative(setIndex, 'Set index'); _requireNonNegative(accumulatedMs, 'Accumulated milliseconds'); if (status == ActiveSetTimerStatus.running && startedAt == null) { throw const DomainException( 'Running set timer requires a start timestamp.', ); } if (stoppedAt != null && skippedAt != null) { throw const DomainException('Set timer cannot be stopped and skipped.'); } } final EntityMetadata metadata; final String activeWorkoutSessionId; final int programIndex; final int exerciseIndex; final int setIndex; final ActiveSetTimerStatus status; final DateTime? startedAt; final int accumulatedMs; final DateTime? stoppedAt; final DateTime? skippedAt; int elapsedMillisecondsAt(DateTime now) { if (status != ActiveSetTimerStatus.running || startedAt == null) { return accumulatedMs; } return accumulatedMs + now.difference(startedAt!).inMilliseconds; } ActiveSetTimerState copyWith({ EntityMetadata? metadata, ActiveSetTimerStatus? status, Object? startedAt = _unchanged, int? accumulatedMs, Object? stoppedAt = _unchanged, Object? skippedAt = _unchanged, }) { return ActiveSetTimerState( metadata: metadata ?? this.metadata, activeWorkoutSessionId: activeWorkoutSessionId, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, status: status ?? this.status, startedAt: startedAt == _unchanged ? this.startedAt : startedAt as DateTime?, accumulatedMs: accumulatedMs ?? this.accumulatedMs, stoppedAt: stoppedAt == _unchanged ? this.stoppedAt : stoppedAt as DateTime?, skippedAt: skippedAt == _unchanged ? this.skippedAt : skippedAt 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, this.pausedAt, this.accumulatedPausedMs = 0, }); 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 DateTime? pausedAt; final int accumulatedPausedMs; int elapsedMillisecondsAt(DateTime now) { final effectiveNow = pausedAt ?? now; final elapsed = effectiveNow.difference(startedAt).inMilliseconds - accumulatedPausedMs; return elapsed < 0 ? 0 : elapsed; } int remainingMillisecondsAt(DateTime now) { final remaining = adjustedRestSeconds * 1000 - elapsedMillisecondsAt(now); return remaining < 0 ? 0 : remaining; } ActiveRestState copyWith({ EntityMetadata? metadata, int? adjustedRestSeconds, Object? endedAt = _unchanged, Object? skippedAt = _unchanged, Object? pausedAt = _unchanged, int? accumulatedPausedMs, }) { 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?, pausedAt: pausedAt == _unchanged ? this.pausedAt : pausedAt as DateTime?, accumulatedPausedMs: accumulatedPausedMs ?? this.accumulatedPausedMs, ); } } final class ActiveExerciseStepProgressState { ActiveExerciseStepProgressState({ required this.metadata, required this.activeWorkoutSessionId, required this.programIndex, required this.exerciseIndex, required this.setIndex, required this.currentPassageIndex, required this.currentStepIndex, required this.currentStepSnapshotId, required this.status, this.startedAt, required this.accumulatedMs, required this.lastTransitionAt, }) { _requireNonNegative(programIndex, 'Program index'); _requireNonNegative(exerciseIndex, 'Exercise index'); _requireNonNegative(setIndex, 'Set index'); _requireNonNegative(currentPassageIndex, 'Current passage index'); _requireNonNegative(currentStepIndex, 'Current step index'); _nonBlank(currentStepSnapshotId, 'Current step snapshot id'); _requireNonNegative(accumulatedMs, 'Accumulated milliseconds'); if (status == ActiveExerciseStepProgressStatus.runningTimer && startedAt == null) { throw const DomainException( 'Running exercise step timer requires a start timestamp.', ); } } final EntityMetadata metadata; final String activeWorkoutSessionId; final int programIndex; final int exerciseIndex; final int setIndex; final int currentPassageIndex; final int currentStepIndex; final String currentStepSnapshotId; final ActiveExerciseStepProgressStatus status; final DateTime? startedAt; final int accumulatedMs; final DateTime lastTransitionAt; int elapsedMillisecondsAt(DateTime now) { if (status != ActiveExerciseStepProgressStatus.runningTimer || startedAt == null) { return accumulatedMs; } return accumulatedMs + now.difference(startedAt!).inMilliseconds; } ActiveExerciseStepProgressState copyWith({ EntityMetadata? metadata, int? currentPassageIndex, int? currentStepIndex, String? currentStepSnapshotId, ActiveExerciseStepProgressStatus? status, Object? startedAt = _unchanged, int? accumulatedMs, DateTime? lastTransitionAt, }) { return ActiveExerciseStepProgressState( metadata: metadata ?? this.metadata, activeWorkoutSessionId: activeWorkoutSessionId, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, currentPassageIndex: currentPassageIndex ?? this.currentPassageIndex, currentStepIndex: currentStepIndex ?? this.currentStepIndex, currentStepSnapshotId: currentStepSnapshotId ?? this.currentStepSnapshotId, status: status ?? this.status, startedAt: startedAt == _unchanged ? this.startedAt : startedAt as DateTime?, accumulatedMs: accumulatedMs ?? this.accumulatedMs, lastTransitionAt: lastTransitionAt ?? this.lastTransitionAt, ); } } final class ActiveExerciseStepResult { ActiveExerciseStepResult({ required this.metadata, required this.activeWorkoutSessionId, required this.programSnapshotId, required this.exerciseSnapshotId, required this.programIndex, required this.exerciseIndex, required this.setIndex, required this.passageIndex, required this.stepIndex, required this.stepSnapshotId, required this.stepNameSnapshot, required this.stepTypeSnapshot, required this.targetValueSnapshot, required this.hasScoreSnapshot, this.scoreInputModeSnapshot, this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.targetScoreSnapshot, this.targetScoreTimeMsSnapshot, required this.status, this.startedAt, this.completedAt, this.actualTimeMs, this.actualReps, this.actualScore, this.actualScoreTimeMs, this.note, }) { _validateExerciseStepResult( programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, passageIndex: passageIndex, stepIndex: stepIndex, stepTypeSnapshot: stepTypeSnapshot, targetValueSnapshot: targetValueSnapshot, hasScoreSnapshot: hasScoreSnapshot, scoreInputModeSnapshot: scoreInputModeSnapshot, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, targetScoreSnapshot: targetScoreSnapshot, targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot, status: status, actualTimeMs: actualTimeMs, actualReps: actualReps, 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 int passageIndex; final int stepIndex; final String stepSnapshotId; final String stepNameSnapshot; final ExerciseStepType stepTypeSnapshot; final int targetValueSnapshot; final bool hasScoreSnapshot; final ScoreInputMode? scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final double? targetScoreSnapshot; final int? targetScoreTimeMsSnapshot; final SetResultStatus status; final DateTime? startedAt; final DateTime? completedAt; final int? actualTimeMs; final int? actualReps; final double? actualScore; final int? actualScoreTimeMs; final String? note; } final class WorkoutHistory { 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.averageHeartRateBpm, this.maxHeartRateBpm, this.results = const [], this.stepResults = const [], }) { _requireNullablePositiveDouble( averageHeartRateBpm, 'Average heart rate bpm', ); _requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm'); } 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 double? averageHeartRateBpm; final int? maxHeartRateBpm; final List results; final List stepResults; WorkoutHistory copyWith({ EntityMetadata? metadata, Object? averageHeartRateBpm = _unchanged, Object? maxHeartRateBpm = _unchanged, }) { return WorkoutHistory( metadata: metadata ?? this.metadata, sourceWorkoutTemplateId: sourceWorkoutTemplateId, sourceActiveWorkoutSessionId: sourceActiveWorkoutSessionId, nameSnapshot: nameSnapshot, startedAt: startedAt, endedAt: endedAt, totalActiveMs: totalActiveMs, completed: completed, historySnapshotJson: historySnapshotJson, averageHeartRateBpm: averageHeartRateBpm == _unchanged ? this.averageHeartRateBpm : averageHeartRateBpm as double?, maxHeartRateBpm: maxHeartRateBpm == _unchanged ? this.maxHeartRateBpm : maxHeartRateBpm as int?, results: results, stepResults: stepResults, ); } } 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.sourceExerciseIdSnapshot, 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 String? sourceExerciseIdSnapshot; final DateTime? startedAt; final DateTime? completedAt; final SetResultStatus status; } final class WorkoutHistoryStepResult { WorkoutHistoryStepResult({ required this.metadata, required this.workoutHistoryId, required this.programSnapshotId, required this.exerciseSnapshotId, required this.programIndex, required this.exerciseIndex, required this.setIndex, required this.passageIndex, required this.stepIndex, required this.stepSnapshotId, required this.stepNameSnapshot, required this.stepTypeSnapshot, required this.targetValueSnapshot, required this.hasScoreSnapshot, this.scoreInputModeSnapshot, this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.targetScoreSnapshot, this.targetScoreTimeMsSnapshot, required this.status, this.startedAt, this.completedAt, this.actualTimeMs, this.actualReps, this.actualScore, this.actualScoreTimeMs, this.note, this.sourceExerciseIdSnapshot, }) { _validateExerciseStepResult( programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, passageIndex: passageIndex, stepIndex: stepIndex, stepTypeSnapshot: stepTypeSnapshot, targetValueSnapshot: targetValueSnapshot, hasScoreSnapshot: hasScoreSnapshot, scoreInputModeSnapshot: scoreInputModeSnapshot, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, targetScoreSnapshot: targetScoreSnapshot, targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot, status: status, actualTimeMs: actualTimeMs, actualReps: actualReps, 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 int passageIndex; final int stepIndex; final String stepSnapshotId; final String stepNameSnapshot; final ExerciseStepType stepTypeSnapshot; final int targetValueSnapshot; final bool hasScoreSnapshot; final ScoreInputMode? scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final double? targetScoreSnapshot; final int? targetScoreTimeMsSnapshot; final SetResultStatus status; final DateTime? startedAt; final DateTime? completedAt; final int? actualTimeMs; final int? actualReps; final double? actualScore; final int? actualScoreTimeMs; final String? note; final String? sourceExerciseIdSnapshot; } 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 normalizeTags(Iterable tags) { final normalized = []; final seen = {}; for (final tag in tags) { final value = tag.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase(); if (value.isEmpty) { throw const DomainException('Tag must not be blank.'); } if (value.length > 24) { throw const DomainException('Tag must be 24 characters or less.'); } if (!seen.add(value)) { throw const DomainException('Tags must be unique.'); } normalized.add(value); } if (normalized.length > 8) { throw const DomainException('An item cannot have more than 8 tags.'); } return List.unmodifiable(normalized); } 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; } List _validatedExerciseSteps(List steps) { if (steps.length > 8) { throw const DomainException('An exercise cannot have more than 8 steps.'); } for (var index = 0; index < steps.length; index++) { if (steps[index].position != index) { throw const DomainException( 'Exercise step positions must be contiguous and ordered.', ); } } final ids = steps.map((step) => _nonBlank(step.id, 'Exercise step id')); if (ids.toSet().length != steps.length) { throw const DomainException('Exercise step ids must be unique.'); } return List.unmodifiable(steps); } 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 _requireNullablePositiveDouble(double? value, String label) { if (value != null && value <= 0) { throw DomainException('$label must be positive.'); } } 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 _validateExerciseStepScoreShape({ required bool hasScore, required ScoreInputMode scoreInputMode, required String? scoreLabel, required String? scoreUnit, required double? defaultTargetScore, required int? defaultTargetScoreTimeMs, required bool linkedToSeriesScore, }) { if (!hasScore) { if (scoreLabel != null || scoreUnit != null || defaultTargetScore != null || defaultTargetScoreTimeMs != null || linkedToSeriesScore) { throw const DomainException( 'Disabled exercise step score must not define score values.', ); } return; } if (defaultTargetScore != null && defaultTargetScoreTimeMs != null) { throw const DomainException( 'Manual and stopwatch step score targets cannot both be set.', ); } switch (scoreInputMode) { case ScoreInputMode.manual: _nonBlank(scoreLabel, 'Exercise step score label'); _nonBlank(scoreUnit, 'Exercise step score unit'); _requireNullableNonNegativeDouble( defaultTargetScore, 'Exercise step default target score', ); if (defaultTargetScoreTimeMs != null) { throw const DomainException( 'Manual exercise step score cannot define stopwatch target.', ); } case ScoreInputMode.stopwatch: if (linkedToSeriesScore) { throw const DomainException( 'Linked series score requires manual exercise step score.', ); } if (scoreLabel != null || scoreUnit != null || defaultTargetScore != null) { throw const DomainException( 'Stopwatch exercise step score cannot define manual score values.', ); } _requireNullablePositive( defaultTargetScoreTimeMs, 'Exercise step default target score time ms', ); } } 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.', ); } } void _requireLinkedStepSeriesScoreShape({ required List steps, required bool scoreEnabled, required ScoreInputMode scoreInputMode, }) { if (steps.any((step) => step.linkedToSeriesScore) && (!scoreEnabled || scoreInputMode != ScoreInputMode.manual)) { throw const DomainException( 'Linked step scores require manual series score.', ); } } void _validateExerciseStepResult({ required int programIndex, required int exerciseIndex, required int setIndex, required int passageIndex, required int stepIndex, required ExerciseStepType stepTypeSnapshot, required int targetValueSnapshot, required bool hasScoreSnapshot, required ScoreInputMode? scoreInputModeSnapshot, required String? scoreLabelSnapshot, required String? scoreUnitSnapshot, required double? targetScoreSnapshot, required int? targetScoreTimeMsSnapshot, required SetResultStatus status, required int? actualTimeMs, required int? actualReps, required double? actualScore, required int? actualScoreTimeMs, }) { _requireNonNegative(programIndex, 'Program index'); _requireNonNegative(exerciseIndex, 'Exercise index'); _requireNonNegative(setIndex, 'Set index'); _requireNonNegative(passageIndex, 'Passage index'); _requireNonNegative(stepIndex, 'Step index'); _requirePositive(targetValueSnapshot, 'Step target value snapshot'); _requireNullableNonNegative(actualTimeMs, 'Actual time milliseconds'); _requireNullableNonNegative(actualReps, 'Actual reps'); _requireNullableNonNegativeDouble(actualScore, 'Actual score'); _requireNullableNonNegative(actualScoreTimeMs, 'Actual score time ms'); if (status == SetResultStatus.skipped && (actualTimeMs != null || actualReps != null || actualScore != null || actualScoreTimeMs != null)) { throw const DomainException('Skipped step results cannot have values.'); } if (actualTimeMs != null && stepTypeSnapshot != ExerciseStepType.time) { throw const DomainException('Actual time requires a time step.'); } if (actualReps != null && stepTypeSnapshot != ExerciseStepType.reps) { throw const DomainException('Actual reps requires a reps step.'); } final mode = scoreInputModeSnapshot ?? ScoreInputMode.manual; _requireScoreTargetShape( scoreEnabled: hasScoreSnapshot, scoreInputMode: mode, targetScore: targetScoreSnapshot, targetScoreTimeMs: targetScoreTimeMsSnapshot, ); _requireScoreResultShape( scoreInputMode: mode, actualScore: actualScore, actualScoreTimeMs: actualScoreTimeMs, ); if (!hasScoreSnapshot && (actualScore != null || actualScoreTimeMs != null)) { throw const DomainException('Step score result requires active score.'); } if (actualScore != null) { _nonBlank(scoreLabelSnapshot, 'Step score label snapshot'); _nonBlank(scoreUnitSnapshot, 'Step score unit snapshot'); } }