Files
GameTime/lib/domain/entities.dart
Blomios fe3608cc4a feat(server): implemente synchronisation et serveur avec fixtures (#187)
- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 23:07:31 +02:00

2477 lines
77 KiB
Dart

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 BusinessExerciseType {
shoot,
freeThrows,
dribble,
finishing,
conditioning,
defense,
mobility,
highIntensity,
recovery,
}
enum HealthServicesExerciseType {
running('RUNNING'),
walking('WALKING'),
highIntensityIntervalTraining('HIGH_INTENSITY_INTERVAL_TRAINING'),
workout('WORKOUT');
const HealthServicesExerciseType(this.wireName);
final String wireName;
}
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, pack }
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<String> 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,
List<BusinessExerciseType> businessTypes = const [],
this.isExample = false,
List<String> tags = const [],
List<ExerciseStep> steps = const [],
this.archivedAt,
}) : name = _nonBlank(name, 'Exercise name'),
tags = normalizeTags(tags),
imageMediaIds = _resolvedExerciseImageMediaIds(
imageMediaId,
imageMediaIds,
),
iconMediaId = _validatedIconMediaId(
iconMediaId,
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
),
businessTypes = _validatedBusinessExerciseTypes(businessTypes),
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',
);
_requireScoreTargetShape(
scoreEnabled: hasScoreMeasure,
scoreInputMode: scoreInputMode,
targetScore: defaultTargetScore,
targetScoreTimeMs: defaultTargetScoreTimeMs,
);
_requireLinkedStepSeriesScoreShape(
steps: steps,
scoreEnabled: hasScoreMeasure,
scoreInputMode: scoreInputMode,
);
}
final EntityMetadata metadata;
final String name;
final String? description;
final List<String> 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 List<BusinessExerciseType> businessTypes;
final bool isExample;
final List<String> tags;
final List<ExerciseStep> steps;
final DateTime? archivedAt;
Set<WorkoutMeasure> get availableMeasures => {
if (hasTimeMeasure) WorkoutMeasure.time,
if (hasRepsMeasure) WorkoutMeasure.reps,
if (hasScoreMeasure) WorkoutMeasure.score,
};
String? get imageMediaId =>
imageMediaIds.isEmpty ? null : imageMediaIds.first;
List<BusinessExerciseType> get effectiveBusinessTypes => businessTypes.isEmpty
? _businessTypesFromCategory(category)
: businessTypes;
List<HealthServicesExerciseType> get healthServicesExerciseTypeStrategy =>
healthServicesExerciseTypeStrategyForBusinessTypes(
effectiveBusinessTypes,
);
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,
Object? businessTypes = _unchanged,
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<String>;
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,
businessTypes: businessTypes == _unchanged
? this.businessTypes
: businessTypes as List<BusinessExerciseType>,
isExample: isExample ?? this.isExample,
tags: tags == _unchanged ? this.tags : tags as List<String>,
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
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<String, Object?> 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<String> 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<String> tags;
final List<ProgramExercise> exercises;
Program copyWith({
List<ProgramExercise>? 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<String>,
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<String> exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
List<HealthServicesExerciseType>
healthServicesExerciseTypeStrategySnapshot =
const [],
List<ExerciseStep> 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,
),
healthServicesExerciseTypeStrategySnapshot =
_validatedHealthServicesExerciseTypes(
healthServicesExerciseTypeStrategySnapshot.isEmpty
? legacyHealthServicesExerciseTypeStrategy
: healthServicesExerciseTypeStrategySnapshot,
),
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<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final List<HealthServicesExerciseType>
healthServicesExerciseTypeStrategySnapshot;
final List<ExerciseStep> 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<WorkoutMeasure> 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,
healthServicesExerciseTypeStrategySnapshot:
exercise.healthServicesExerciseTypeStrategy,
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<String, Object?> toSnapshotJson() => {
'id': metadata.id,
'sourceExerciseId': sourceExerciseId,
'position': position,
'exerciseNameSnapshot': exerciseNameSnapshot,
'exerciseDescriptionSnapshot': exerciseDescriptionSnapshot,
'exerciseImageMediaIdSnapshot': exerciseImageMediaIdSnapshot,
'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot,
'healthServicesExerciseTypeStrategy':
healthServicesExerciseTypeStrategySnapshot
.map((type) => type.wireName)
.toList(),
'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<String> 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<String> tags;
final List<WorkoutTemplateProgram> programs;
final List<WorkoutTemplateExerciseOverride> overrides;
}
final class WorkoutTemplateProgram {
WorkoutTemplateProgram({
required this.metadata,
required this.workoutTemplateId,
this.sourceProgramId,
required this.position,
required String programNameSnapshot,
required this.defaultRestSecondsSnapshot,
required this.programSnapshotJson,
}) : programNameSnapshot = _nonBlank(
programNameSnapshot,
'Program snapshot name',
) {
_requireNonNegative(position, 'Position');
_requireNonNegative(defaultRestSecondsSnapshot, 'Default rest seconds');
}
final EntityMetadata metadata;
final String workoutTemplateId;
final String? sourceProgramId;
final int position;
final String programNameSnapshot;
final int defaultRestSecondsSnapshot;
final String programSnapshotJson;
static WorkoutTemplateProgram snapshotFromProgram({
required EntityMetadata metadata,
required String workoutTemplateId,
required Program program,
required int position,
}) {
return WorkoutTemplateProgram(
metadata: metadata,
workoutTemplateId: workoutTemplateId,
sourceProgramId: program.metadata.id,
position: position,
programNameSnapshot: program.name,
defaultRestSecondsSnapshot: program.defaultRestSeconds,
programSnapshotJson: jsonEncode({
'programId': program.metadata.id,
'name': program.name,
'defaultRestSeconds': program.defaultRestSeconds,
'exercises': program.exercises
.map((exercise) => exercise.toSnapshotJson())
.toList(),
}),
);
}
}
final class WorkoutTemplateExerciseOverride {
WorkoutTemplateExerciseOverride({
required this.metadata,
required this.workoutTemplateProgramId,
required String snapshotProgramExerciseId,
this.setsCountOverride,
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
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.minHeartRateBpm,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.totalDistanceMeters,
this.totalCaloriesKcal,
this.results = const [],
this.stepResults = const [],
}) {
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
_requireNullablePositiveDouble(
averageHeartRateBpm,
'Average heart rate bpm',
);
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
_requireNullableNonNegativeDouble(
totalDistanceMeters,
'Total distance meters',
);
_requireNullableNonNegativeDouble(totalCaloriesKcal, 'Total calories kcal');
}
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 int? minHeartRateBpm;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final double? totalDistanceMeters;
final double? totalCaloriesKcal;
final List<WorkoutHistorySetResult> results;
final List<WorkoutHistoryStepResult> stepResults;
WorkoutHistory copyWith({
EntityMetadata? metadata,
Object? minHeartRateBpm = _unchanged,
Object? averageHeartRateBpm = _unchanged,
Object? maxHeartRateBpm = _unchanged,
Object? totalDistanceMeters = _unchanged,
Object? totalCaloriesKcal = _unchanged,
}) {
return WorkoutHistory(
metadata: metadata ?? this.metadata,
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
sourceActiveWorkoutSessionId: sourceActiveWorkoutSessionId,
nameSnapshot: nameSnapshot,
startedAt: startedAt,
endedAt: endedAt,
totalActiveMs: totalActiveMs,
completed: completed,
historySnapshotJson: historySnapshotJson,
minHeartRateBpm: minHeartRateBpm == _unchanged
? this.minHeartRateBpm
: minHeartRateBpm as int?,
averageHeartRateBpm: averageHeartRateBpm == _unchanged
? this.averageHeartRateBpm
: averageHeartRateBpm as double?,
maxHeartRateBpm: maxHeartRateBpm == _unchanged
? this.maxHeartRateBpm
: maxHeartRateBpm as int?,
totalDistanceMeters: totalDistanceMeters == _unchanged
? this.totalDistanceMeters
: totalDistanceMeters as double?,
totalCaloriesKcal: totalCaloriesKcal == _unchanged
? this.totalCaloriesKcal
: totalCaloriesKcal as double?,
results: results,
stepResults: stepResults,
);
}
}
enum WorkoutTelemetryAggregateScope {
session('session'),
exercise('exercise'),
set('set'),
step('step');
const WorkoutTelemetryAggregateScope(this.wireName);
final String wireName;
static WorkoutTelemetryAggregateScope parse(String value) {
for (final scope in values) {
if (scope.wireName == value) {
return scope;
}
}
throw DomainException('Unsupported telemetry scope: $value.');
}
}
final class WorkoutTelemetrySample {
WorkoutTelemetrySample({
required String id,
required String sessionId,
required DateTime capturedAt,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
this.programSnapshotId,
this.exerciseSnapshotId,
this.stepSnapshotId,
this.heartRateBpm,
this.distanceMeters,
this.caloriesKcal,
}) : id = _nonBlank(id, 'Telemetry sample id'),
sessionId = _nonBlank(sessionId, 'Telemetry session id'),
capturedAt = capturedAt.toUtc() {
_requireNullableNonNegative(programIndex, 'Program index');
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
_requireNullableNonNegative(setIndex, 'Set index');
_requireNullableNonNegative(passageIndex, 'Passage index');
_requireNullableNonNegative(stepIndex, 'Step index');
_requireNullablePositive(heartRateBpm, 'Heart rate bpm');
_requireNullableNonNegativeDouble(distanceMeters, 'Distance meters');
_requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal');
if (heartRateBpm == null &&
distanceMeters == null &&
caloriesKcal == null) {
throw const DomainException(
'Telemetry sample must contain at least one metric.',
);
}
}
final String id;
final String sessionId;
final DateTime capturedAt;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final String? programSnapshotId;
final String? exerciseSnapshotId;
final String? stepSnapshotId;
final int? heartRateBpm;
final double? distanceMeters;
final double? caloriesKcal;
}
final class ActiveWorkoutTelemetryWindowState {
ActiveWorkoutTelemetryWindowState({
required String sessionId,
required this.windowStartedActiveMs,
required this.latestCapturedAt,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
this.programSnapshotId,
this.exerciseSnapshotId,
this.stepSnapshotId,
this.heartRateBpm,
this.distanceMeters,
this.caloriesKcal,
}) : sessionId = _nonBlank(sessionId, 'Telemetry window session id') {
_requireNonNegative(windowStartedActiveMs, 'Telemetry window active ms');
_requireNullableNonNegative(programIndex, 'Program index');
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
_requireNullableNonNegative(setIndex, 'Set index');
_requireNullableNonNegative(passageIndex, 'Passage index');
_requireNullableNonNegative(stepIndex, 'Step index');
_requireNullablePositive(heartRateBpm, 'Heart rate bpm');
_requireNullableNonNegativeDouble(distanceMeters, 'Distance meters');
_requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal');
if (heartRateBpm == null &&
distanceMeters == null &&
caloriesKcal == null) {
throw const DomainException(
'Telemetry window state must contain at least one metric.',
);
}
}
final String sessionId;
final int windowStartedActiveMs;
final DateTime latestCapturedAt;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final String? programSnapshotId;
final String? exerciseSnapshotId;
final String? stepSnapshotId;
final int? heartRateBpm;
final double? distanceMeters;
final double? caloriesKcal;
WorkoutTelemetrySample toSample(String id) {
return WorkoutTelemetrySample(
id: id,
sessionId: sessionId,
capturedAt: latestCapturedAt,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
programSnapshotId: programSnapshotId,
exerciseSnapshotId: exerciseSnapshotId,
stepSnapshotId: stepSnapshotId,
heartRateBpm: heartRateBpm,
distanceMeters: distanceMeters,
caloriesKcal: caloriesKcal,
);
}
}
final class WorkoutTelemetryAggregate {
WorkoutTelemetryAggregate({
required String sessionId,
required this.scope,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
required this.sampleCount,
this.minHeartRateBpm,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.totalDistanceMeters,
this.totalCaloriesKcal,
}) : sessionId = _nonBlank(sessionId, 'Telemetry aggregate session id') {
_requireNullableNonNegative(programIndex, 'Program index');
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
_requireNullableNonNegative(setIndex, 'Set index');
_requireNullableNonNegative(passageIndex, 'Passage index');
_requireNullableNonNegative(stepIndex, 'Step index');
_requireNonNegative(sampleCount, 'Telemetry sample count');
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
_requireNullablePositiveDouble(
averageHeartRateBpm,
'Average heart rate bpm',
);
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
_requireNullableNonNegativeDouble(
totalDistanceMeters,
'Total distance meters',
);
_requireNullableNonNegativeDouble(totalCaloriesKcal, 'Total calories kcal');
}
final String sessionId;
final WorkoutTelemetryAggregateScope scope;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final int sampleCount;
final int? minHeartRateBpm;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final double? totalDistanceMeters;
final double? totalCaloriesKcal;
}
final class WorkoutTelemetryGraphPoint {
WorkoutTelemetryGraphPoint({
required this.capturedAt,
required this.elapsedMs,
this.heartRateBpm,
this.distanceMeters,
this.caloriesKcal,
required this.startsAfterGap,
}) {
_requireNonNegative(elapsedMs, 'Telemetry graph elapsed ms');
_requireNullablePositive(heartRateBpm, 'Heart rate bpm');
_requireNullableNonNegativeDouble(distanceMeters, 'Distance meters');
_requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal');
if (heartRateBpm == null &&
distanceMeters == null &&
caloriesKcal == null) {
throw const DomainException(
'Telemetry graph point must contain at least one metric.',
);
}
}
final DateTime capturedAt;
final int elapsedMs;
final int? heartRateBpm;
final double? distanceMeters;
final double? caloriesKcal;
final bool startsAfterGap;
}
final class WorkoutTelemetryGraphSeries {
WorkoutTelemetryGraphSeries({
required this.scope,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
required this.points,
this.minHeartRateBpm,
this.maxHeartRateBpm,
}) {
_requireNullableNonNegative(programIndex, 'Program index');
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
_requireNullableNonNegative(setIndex, 'Set index');
_requireNullableNonNegative(passageIndex, 'Passage index');
_requireNullableNonNegative(stepIndex, 'Step index');
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
}
final WorkoutTelemetryAggregateScope scope;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final List<WorkoutTelemetryGraphPoint> points;
final int? minHeartRateBpm;
final int? maxHeartRateBpm;
}
final class ScopeInstanceDescriptor {
ScopeInstanceDescriptor({
required this.scope,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
required this.ordinal,
required DateTime firstCapturedAt,
}) : firstCapturedAt = firstCapturedAt.toUtc() {
_requireNullableNonNegative(programIndex, 'Program index');
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
_requireNullableNonNegative(setIndex, 'Set index');
_requireNullableNonNegative(passageIndex, 'Passage index');
_requireNullableNonNegative(stepIndex, 'Step index');
_requirePositive(ordinal, 'Scope instance ordinal');
}
final WorkoutTelemetryAggregateScope scope;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final int ordinal;
final DateTime firstCapturedAt;
}
enum ScopeMarkerBoundary { start, end }
final class ScopeMarker {
ScopeMarker({
required this.elapsedMs,
required String label,
required this.boundary,
}) : label = _nonBlank(label, 'Scope marker label') {
_requireNonNegative(elapsedMs, 'Scope marker elapsed ms');
}
final int elapsedMs;
final String label;
final ScopeMarkerBoundary boundary;
}
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<String> normalizeTags(Iterable<String> tags) {
final normalized = <String>[];
final seen = <String>{};
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<String> _validatedImageMediaIds(List<String> 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<String> _resolvedExerciseImageMediaIds(
String? imageMediaId,
List<String> imageMediaIds,
) {
return _validatedImageMediaIds(
imageMediaIds.isEmpty && imageMediaId != null
? [imageMediaId]
: imageMediaIds,
);
}
String? _validatedIconMediaId(String? iconMediaId, List<String> 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<ExerciseStep> _validatedExerciseSteps(List<ExerciseStep> 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);
}
List<BusinessExerciseType> _validatedBusinessExerciseTypes(
List<BusinessExerciseType> types,
) {
final unique = <BusinessExerciseType>[];
for (final type in types) {
if (!unique.contains(type)) {
unique.add(type);
}
}
return List.unmodifiable(unique);
}
List<HealthServicesExerciseType> _validatedHealthServicesExerciseTypes(
List<HealthServicesExerciseType> types,
) {
final unique = <HealthServicesExerciseType>[];
for (final type in types) {
if (!unique.contains(type)) {
unique.add(type);
}
}
return List.unmodifiable(unique);
}
const legacyHealthServicesExerciseTypeStrategy = [
HealthServicesExerciseType.running,
HealthServicesExerciseType.walking,
HealthServicesExerciseType.highIntensityIntervalTraining,
HealthServicesExerciseType.workout,
];
List<BusinessExerciseType> _businessTypesFromCategory(
ExerciseCategory category,
) {
return switch (category) {
ExerciseCategory.shoot => const [BusinessExerciseType.shoot],
ExerciseCategory.freeThrows => const [BusinessExerciseType.freeThrows],
ExerciseCategory.dribble => const [BusinessExerciseType.dribble],
ExerciseCategory.finishing => const [BusinessExerciseType.finishing],
ExerciseCategory.conditioning => const [BusinessExerciseType.conditioning],
ExerciseCategory.defense => const [BusinessExerciseType.defense],
ExerciseCategory.mobility => const [BusinessExerciseType.mobility],
ExerciseCategory.uncategorized => const [],
};
}
List<HealthServicesExerciseType>
healthServicesExerciseTypeStrategyForBusinessTypes(
List<BusinessExerciseType> types,
) {
if (types.isEmpty) {
return legacyHealthServicesExerciseTypeStrategy;
}
final output = <HealthServicesExerciseType>[];
void add(HealthServicesExerciseType type) {
if (!output.contains(type)) {
output.add(type);
}
}
for (final type in types) {
switch (type) {
case BusinessExerciseType.dribble:
case BusinessExerciseType.finishing:
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.highIntensityIntervalTraining);
add(HealthServicesExerciseType.workout);
case BusinessExerciseType.conditioning:
case BusinessExerciseType.highIntensity:
case BusinessExerciseType.defense:
add(HealthServicesExerciseType.highIntensityIntervalTraining);
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.workout);
case BusinessExerciseType.mobility:
case BusinessExerciseType.recovery:
add(HealthServicesExerciseType.walking);
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.workout);
case BusinessExerciseType.shoot:
case BusinessExerciseType.freeThrows:
add(HealthServicesExerciseType.highIntensityIntervalTraining);
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.walking);
add(HealthServicesExerciseType.workout);
}
}
return List.unmodifiable(output);
}
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<ExerciseStep> 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');
}
}