Étend le modèle Drift (migration schemaVersion 13→14, ADD COLUMN) et la couche application (entités, use cases, repositories) pour supporter l'auto-enchaînement des chronos d'étapes. flutter pub get OK, build_runner OK, analyze propre (mêmes infos préexistantes), 141/141 tests verts, build APK debug validé. Premier ticket du chantier "auto-enchaînement des chronos d'étapes" (#73). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1726 lines
54 KiB
Dart
1726 lines
54 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 ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
|
|
|
|
enum SetResultStatus { completed, skipped }
|
|
|
|
enum ActiveScoreStopwatchStatus { running, 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<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,
|
|
List<ExerciseStep> steps = const [],
|
|
this.archivedAt,
|
|
}) : name = _nonBlank(name, 'Exercise name'),
|
|
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',
|
|
);
|
|
}
|
|
|
|
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 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;
|
|
|
|
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,
|
|
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,
|
|
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,
|
|
}) : 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,
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
const Object _unchanged = Object();
|
|
|
|
final class Program {
|
|
Program({
|
|
required this.metadata,
|
|
required String name,
|
|
required this.defaultRestSeconds,
|
|
this.exercises = const [],
|
|
}) : name = _nonBlank(name, 'Program name') {
|
|
_requireNonNegative(defaultRestSeconds, 'Default rest seconds');
|
|
}
|
|
|
|
final EntityMetadata metadata;
|
|
final String name;
|
|
final int defaultRestSeconds;
|
|
final List<ProgramExercise> exercises;
|
|
|
|
Program copyWith({
|
|
List<ProgramExercise>? exercises,
|
|
EntityMetadata? metadata,
|
|
String? name,
|
|
int? defaultRestSeconds,
|
|
}) {
|
|
return Program(
|
|
metadata: metadata ?? this.metadata,
|
|
name: name ?? this.name,
|
|
defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds,
|
|
exercises: exercises ?? this.exercises,
|
|
);
|
|
}
|
|
}
|
|
|
|
final class ProgramExercise {
|
|
ProgramExercise({
|
|
required this.metadata,
|
|
required this.programId,
|
|
this.sourceExerciseId,
|
|
required this.position,
|
|
required String exerciseNameSnapshot,
|
|
this.exerciseDescriptionSnapshot,
|
|
this.exerciseImageMediaIdSnapshot,
|
|
List<String> exerciseImageMediaIdsSnapshot = const [],
|
|
this.exerciseVideoMediaIdSnapshot,
|
|
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,
|
|
),
|
|
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,
|
|
);
|
|
}
|
|
|
|
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<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,
|
|
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,
|
|
'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.programs = const [],
|
|
this.overrides = const [],
|
|
}) : name = _nonBlank(name, 'Workout template name');
|
|
|
|
final EntityMetadata metadata;
|
|
final String name;
|
|
final DateTime? lastStartedAt;
|
|
final List<WorkoutTemplateProgram> programs;
|
|
final List<WorkoutTemplateExerciseOverride> overrides;
|
|
}
|
|
|
|
final class WorkoutTemplateProgram {
|
|
WorkoutTemplateProgram({
|
|
required this.metadata,
|
|
required this.workoutTemplateId,
|
|
this.sourceProgramId,
|
|
required this.position,
|
|
required String programNameSnapshot,
|
|
required this.defaultRestSecondsSnapshot,
|
|
required this.programSnapshotJson,
|
|
}) : programNameSnapshot = _nonBlank(
|
|
programNameSnapshot,
|
|
'Program snapshot name',
|
|
) {
|
|
_requireNonNegative(position, 'Position');
|
|
_requireNonNegative(defaultRestSecondsSnapshot, 'Default rest seconds');
|
|
}
|
|
|
|
final EntityMetadata metadata;
|
|
final String workoutTemplateId;
|
|
final String? sourceProgramId;
|
|
final int position;
|
|
final String programNameSnapshot;
|
|
final int defaultRestSecondsSnapshot;
|
|
final String programSnapshotJson;
|
|
|
|
static WorkoutTemplateProgram snapshotFromProgram({
|
|
required EntityMetadata metadata,
|
|
required String workoutTemplateId,
|
|
required Program program,
|
|
required int position,
|
|
}) {
|
|
return WorkoutTemplateProgram(
|
|
metadata: metadata,
|
|
workoutTemplateId: workoutTemplateId,
|
|
sourceProgramId: program.metadata.id,
|
|
position: position,
|
|
programNameSnapshot: program.name,
|
|
defaultRestSecondsSnapshot: program.defaultRestSeconds,
|
|
programSnapshotJson: jsonEncode({
|
|
'programId': program.metadata.id,
|
|
'name': program.name,
|
|
'defaultRestSeconds': program.defaultRestSeconds,
|
|
'exercises': program.exercises
|
|
.map((exercise) => exercise.toSnapshotJson())
|
|
.toList(),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class WorkoutTemplateExerciseOverride {
|
|
WorkoutTemplateExerciseOverride({
|
|
required this.metadata,
|
|
required this.workoutTemplateProgramId,
|
|
required String snapshotProgramExerciseId,
|
|
this.setsCountOverride,
|
|
this.targetTimeSecondsOverride,
|
|
this.targetRepsOverride,
|
|
this.targetScoreOverride,
|
|
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.stopped) {
|
|
return accumulatedMs;
|
|
}
|
|
return accumulatedMs + now.difference(startedAt).inMilliseconds;
|
|
}
|
|
|
|
ActiveScoreStopwatchState copyWith({
|
|
EntityMetadata? metadata,
|
|
ActiveScoreStopwatchStatus? status,
|
|
DateTime? startedAt,
|
|
int? accumulatedMs,
|
|
Object? stoppedAt = _unchanged,
|
|
}) {
|
|
return ActiveScoreStopwatchState(
|
|
metadata: metadata ?? this.metadata,
|
|
activeWorkoutSessionId: activeWorkoutSessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
status: status ?? this.status,
|
|
startedAt: startedAt ?? this.startedAt,
|
|
accumulatedMs: accumulatedMs ?? this.accumulatedMs,
|
|
stoppedAt: stoppedAt == _unchanged
|
|
? this.stoppedAt
|
|
: stoppedAt as DateTime?,
|
|
);
|
|
}
|
|
}
|
|
|
|
final class ActiveRestState {
|
|
const ActiveRestState({
|
|
required this.metadata,
|
|
required this.activeWorkoutSessionId,
|
|
required this.afterProgramIndex,
|
|
required this.afterExerciseIndex,
|
|
required this.afterSetIndex,
|
|
required this.plannedRestSeconds,
|
|
required this.adjustedRestSeconds,
|
|
required this.startedAt,
|
|
this.endedAt,
|
|
this.skippedAt,
|
|
});
|
|
|
|
final EntityMetadata metadata;
|
|
final String activeWorkoutSessionId;
|
|
final int afterProgramIndex;
|
|
final int afterExerciseIndex;
|
|
final int afterSetIndex;
|
|
final int plannedRestSeconds;
|
|
final int adjustedRestSeconds;
|
|
final DateTime startedAt;
|
|
final DateTime? endedAt;
|
|
final DateTime? skippedAt;
|
|
|
|
ActiveRestState copyWith({
|
|
EntityMetadata? metadata,
|
|
int? adjustedRestSeconds,
|
|
Object? endedAt = _unchanged,
|
|
Object? skippedAt = _unchanged,
|
|
}) {
|
|
return ActiveRestState(
|
|
metadata: metadata ?? this.metadata,
|
|
activeWorkoutSessionId: activeWorkoutSessionId,
|
|
afterProgramIndex: afterProgramIndex,
|
|
afterExerciseIndex: afterExerciseIndex,
|
|
afterSetIndex: afterSetIndex,
|
|
plannedRestSeconds: plannedRestSeconds,
|
|
adjustedRestSeconds: adjustedRestSeconds ?? this.adjustedRestSeconds,
|
|
startedAt: startedAt,
|
|
endedAt: endedAt == _unchanged ? this.endedAt : endedAt as DateTime?,
|
|
skippedAt: skippedAt == _unchanged
|
|
? this.skippedAt
|
|
: skippedAt as DateTime?,
|
|
);
|
|
}
|
|
}
|
|
|
|
final class 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 {
|
|
const WorkoutHistory({
|
|
required this.metadata,
|
|
this.sourceWorkoutTemplateId,
|
|
this.sourceActiveWorkoutSessionId,
|
|
required this.nameSnapshot,
|
|
required this.startedAt,
|
|
required this.endedAt,
|
|
required this.totalActiveMs,
|
|
required this.completed,
|
|
required this.historySnapshotJson,
|
|
this.results = const [],
|
|
this.stepResults = const [],
|
|
});
|
|
|
|
final EntityMetadata metadata;
|
|
final String? sourceWorkoutTemplateId;
|
|
final String? sourceActiveWorkoutSessionId;
|
|
final String nameSnapshot;
|
|
final DateTime startedAt;
|
|
final DateTime endedAt;
|
|
final int totalActiveMs;
|
|
final bool completed;
|
|
final String historySnapshotJson;
|
|
final List<WorkoutHistorySetResult> results;
|
|
final List<WorkoutHistoryStepResult> 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.startedAt,
|
|
this.completedAt,
|
|
this.status = SetResultStatus.completed,
|
|
}) {
|
|
if (status == SetResultStatus.skipped &&
|
|
(actualTimeMs != null ||
|
|
actualReps != null ||
|
|
actualScore != null ||
|
|
actualScoreTimeMs != null)) {
|
|
throw const DomainException(
|
|
'Skipped history results cannot have values.',
|
|
);
|
|
}
|
|
_requireScoreTargetShape(
|
|
scoreEnabled: scoreEnabledSnapshot,
|
|
scoreInputMode: scoreInputModeSnapshot,
|
|
targetScore: targetScoreSnapshot,
|
|
targetScoreTimeMs: targetScoreTimeMsSnapshot,
|
|
);
|
|
_requireScoreResultShape(
|
|
scoreInputMode: scoreInputModeSnapshot,
|
|
actualScore: actualScore,
|
|
actualScoreTimeMs: actualScoreTimeMs,
|
|
);
|
|
}
|
|
|
|
final EntityMetadata metadata;
|
|
final String workoutHistoryId;
|
|
final String programSnapshotId;
|
|
final String exerciseSnapshotId;
|
|
final int programIndex;
|
|
final int exerciseIndex;
|
|
final int setIndex;
|
|
final String programNameSnapshot;
|
|
final String exerciseNameSnapshot;
|
|
final bool timeEnabledSnapshot;
|
|
final bool repsEnabledSnapshot;
|
|
final bool scoreEnabledSnapshot;
|
|
final int? targetTimeSecondsSnapshot;
|
|
final int? targetRepsSnapshot;
|
|
final double? targetScoreSnapshot;
|
|
final int? targetScoreTimeMsSnapshot;
|
|
final int? actualTimeMs;
|
|
final int? actualReps;
|
|
final double? actualScore;
|
|
final int? actualScoreTimeMs;
|
|
final ScoreInputMode scoreInputModeSnapshot;
|
|
final String? scoreLabelSnapshot;
|
|
final String? scoreUnitSnapshot;
|
|
final DateTime? startedAt;
|
|
final DateTime? completedAt;
|
|
final SetResultStatus status;
|
|
}
|
|
|
|
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,
|
|
}) {
|
|
_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;
|
|
}
|
|
|
|
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> _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);
|
|
}
|
|
|
|
void _requireAtLeastOneMeasure({
|
|
required bool hasTime,
|
|
required bool hasReps,
|
|
required bool hasScore,
|
|
}) {
|
|
if (!hasTime && !hasReps && !hasScore) {
|
|
throw const DomainException('At least one measure must be active.');
|
|
}
|
|
}
|
|
|
|
void _requirePositive(int value, String label) {
|
|
if (value <= 0) {
|
|
throw DomainException('$label must be positive.');
|
|
}
|
|
}
|
|
|
|
void _requireNonNegative(int value, String label) {
|
|
if (value < 0) {
|
|
throw DomainException('$label must not be negative.');
|
|
}
|
|
}
|
|
|
|
void _requireNullablePositive(int? value, String label) {
|
|
if (value != null) {
|
|
_requirePositive(value, label);
|
|
}
|
|
}
|
|
|
|
void _requireNullableNonNegative(int? value, String label) {
|
|
if (value != null) {
|
|
_requireNonNegative(value, label);
|
|
}
|
|
}
|
|
|
|
void _requireNullableNonNegativeDouble(double? value, String label) {
|
|
if (value != null && value < 0) {
|
|
throw DomainException('$label must not be negative.');
|
|
}
|
|
}
|
|
|
|
void _requireScoreTargetShape({
|
|
required bool scoreEnabled,
|
|
required ScoreInputMode scoreInputMode,
|
|
required double? targetScore,
|
|
required int? targetScoreTimeMs,
|
|
}) {
|
|
if (targetScore != null && targetScoreTimeMs != null) {
|
|
throw const DomainException(
|
|
'Manual and stopwatch score targets cannot both be set.',
|
|
);
|
|
}
|
|
if (targetScore != null &&
|
|
(!scoreEnabled || scoreInputMode != ScoreInputMode.manual)) {
|
|
throw const DomainException(
|
|
'Manual score target requires manual score mode.',
|
|
);
|
|
}
|
|
if (targetScoreTimeMs != null &&
|
|
(!scoreEnabled || scoreInputMode != ScoreInputMode.stopwatch)) {
|
|
throw const DomainException(
|
|
'Stopwatch score target requires stopwatch score mode.',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _validateExerciseStepScoreShape({
|
|
required bool hasScore,
|
|
required ScoreInputMode scoreInputMode,
|
|
required String? scoreLabel,
|
|
required String? scoreUnit,
|
|
required double? defaultTargetScore,
|
|
required int? defaultTargetScoreTimeMs,
|
|
}) {
|
|
if (!hasScore) {
|
|
if (scoreLabel != null ||
|
|
scoreUnit != null ||
|
|
defaultTargetScore != null ||
|
|
defaultTargetScoreTimeMs != null) {
|
|
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 (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 _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');
|
|
}
|
|
}
|