Files
GameTime/lib/application/use_cases.dart
Blomios cdcb0beead feat(exécution): chrono du score chronométré pendant la séance (ticket #27)
Ajoute le chrono du score chronométré (fondation domain/config des
tickets #25/#26) sur workout_execution_screen.dart, avec les use
cases associés. flutter analyze propre, 75/75 tests verts, build APK
debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:34:36 +02:00

1983 lines
65 KiB
Dart

import 'dart:convert';
import '../domain/domain.dart';
import 'ports.dart';
const Object _useCaseUnchanged = Object();
final class ExerciseUseCases {
const ExerciseUseCases({
required this.repository,
required this.programRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ExerciseRepository repository;
final ProgramRepository programRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<Exercise> create({
required String name,
String? description,
String? imageMediaId,
List<String> imageMediaIds = const [],
String? iconMediaId,
String? videoMediaId,
required bool hasTimeMeasure,
required bool hasRepsMeasure,
required bool hasScoreMeasure,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
int? defaultTargetTimeSeconds,
int? defaultTargetReps,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
}) async {
_validateExerciseDefaultTargets(
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreInputMode: scoreInputMode,
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
final now = clock.now();
final exercise = Exercise(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
description: description,
imageMediaIds: _resolveExerciseImageIds(imageMediaId, imageMediaIds),
iconMediaId: iconMediaId,
videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
await repository.save(exercise);
return exercise;
}
Future<void> save(Exercise exercise) => repository.save(exercise);
Future<List<Exercise>> listActive() => repository.listActive();
Future<bool> isUsedInPrograms(String id) {
return repository.isReferencedByProgram(id);
}
Future<Exercise> update({
required String id,
required String name,
String? description,
String? imageMediaId,
Object? imageMediaIds = _useCaseUnchanged,
Object? iconMediaId = _useCaseUnchanged,
String? videoMediaId,
required bool hasTimeMeasure,
required bool hasRepsMeasure,
required bool hasScoreMeasure,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
Object? defaultTargetTimeSeconds = _useCaseUnchanged,
Object? defaultTargetReps = _useCaseUnchanged,
Object? defaultTargetScore = _useCaseUnchanged,
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
}) async {
final exercise = await repository.findById(id);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final resolvedDefaultTargetTimeSeconds =
defaultTargetTimeSeconds == _useCaseUnchanged
? exercise.defaultTargetTimeSeconds
: defaultTargetTimeSeconds as int?;
final resolvedDefaultTargetReps = defaultTargetReps == _useCaseUnchanged
? exercise.defaultTargetReps
: defaultTargetReps as int?;
final resolvedDefaultTargetScore = defaultTargetScore == _useCaseUnchanged
? exercise.defaultTargetScore
: (defaultTargetScore as num?)?.toDouble();
final resolvedDefaultTargetScoreTimeMs =
defaultTargetScoreTimeMs == _useCaseUnchanged
? exercise.defaultTargetScoreTimeMs
: defaultTargetScoreTimeMs as int?;
_validateExerciseDefaultTargets(
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreInputMode: scoreInputMode,
defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds,
defaultTargetReps: resolvedDefaultTargetReps,
defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
);
final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
: imageMediaIds as List<String>;
final resolvedIconMediaId = iconMediaId == _useCaseUnchanged
? exercise.iconMediaId != null &&
resolvedImageMediaIds.contains(exercise.iconMediaId)
? exercise.iconMediaId
: null
: iconMediaId as String?;
final updated = exercise.copyWith(
metadata: exercise.metadata.touch(clock.now()),
name: name,
description: description,
imageMediaIds: resolvedImageMediaIds,
iconMediaId: resolvedIconMediaId,
videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds,
defaultTargetReps: resolvedDefaultTargetReps,
defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
);
await repository.save(updated);
return updated;
}
Future<Exercise> attachImage({
required String exerciseId,
required String mediaAssetId,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> addImage({
required String exerciseId,
required String mediaAssetId,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> removeImage({
required String exerciseId,
required String mediaAssetId,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
imageMediaIds: exercise.imageMediaIds
.where((imageId) => imageId != mediaAssetId)
.toList(),
iconMediaId: exercise.iconMediaId == mediaAssetId
? null
: exercise.iconMediaId,
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> reorderImages({
required String exerciseId,
required List<String> orderedMediaIds,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
if (!_sameImageSet(exercise.imageMediaIds, orderedMediaIds)) {
throw const DomainException(
'Reordered exercise images must match existing images.',
);
}
final updated = exercise.copyWith(
imageMediaIds: orderedMediaIds,
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> setIcon({
required String exerciseId,
required String? mediaAssetId,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
iconMediaId: mediaAssetId,
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> attachVideo({
required String exerciseId,
required String mediaAssetId,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
videoMediaId: mediaAssetId,
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> archive(String id) async {
final exercise = await repository.findById(id);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final archived = exercise.archive(clock.now());
await repository.save(archived);
return archived;
}
Future<Exercise> delete(String id) async {
final exercise = await repository.findById(id);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final now = clock.now();
final deleted = exercise.copyWith(
metadata: exercise.metadata.markDeleted(now),
);
final programs = await programRepository.listActive();
for (final program in programs) {
final keptExercises = program.exercises
.where((programExercise) => programExercise.sourceExerciseId != id)
.toList();
if (keptExercises.length == program.exercises.length) {
continue;
}
await programRepository.replaceExercises(
program.copyWith(
metadata: program.metadata.touch(now),
exercises: _repositionProgramExercises(keptExercises, now),
),
now,
);
}
await repository.save(deleted);
return deleted;
}
}
final class MediaUseCases {
const MediaUseCases({
required this.mediaRepository,
required this.exerciseRepository,
required this.storage,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final MediaAssetRepository mediaRepository;
final ExerciseRepository exerciseRepository;
final LocalMediaStorage storage;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<MediaAsset> importMedia({
required String sourcePath,
required MediaKind kind,
}) async {
final now = clock.now();
final id = ids.newId();
final stored = await storage.importFile(
sourcePath: sourcePath,
kind: kind,
stableFileName: id,
);
final asset = MediaAsset(
metadata: EntityMetadata(
id: id,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
),
kind: kind,
localUri: stored.localUri,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
width: stored.width,
height: stored.height,
durationMs: stored.durationMs,
);
await mediaRepository.save(asset);
return asset;
}
Future<Exercise> attachToExercise({
required String exerciseId,
required String mediaAssetId,
}) async {
final exercise = await exerciseRepository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final media = await mediaRepository.findById(mediaAssetId);
if (media == null) {
throw const DomainException('Media asset not found.');
}
final updated = switch (media.kind) {
MediaKind.image => exercise.copyWith(
imageMediaIds: _appendExerciseImage(
exercise.imageMediaIds,
media.metadata.id,
),
metadata: exercise.metadata.touch(clock.now()),
),
MediaKind.video => exercise.copyWith(
videoMediaId: media.metadata.id,
metadata: exercise.metadata.touch(clock.now()),
),
};
await exerciseRepository.save(updated);
return updated;
}
Future<int> deleteOrphanedManagedFiles() async {
final activeAssets = await mediaRepository.listActive();
final referencedUris = activeAssets.map((asset) => asset.localUri).toSet();
final managedUris = await storage.listManagedLocalUris();
final orphanedUris = managedUris.difference(referencedUris);
for (final uri in orphanedUris) {
await storage.deleteByLocalUri(uri);
}
return orphanedUris.length;
}
}
final class ProgramUseCases {
const ProgramUseCases({
required this.programRepository,
required this.exerciseRepository,
required this.templateRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ProgramRepository programRepository;
final ExerciseRepository exerciseRepository;
final WorkoutTemplateRepository templateRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<Program?> findById(String id) => programRepository.findById(id);
Future<List<Program>> listActive() => programRepository.listActive();
Future<bool> isUsedInWorkoutTemplates(String id) async {
final templates = await templateRepository.listActive();
return templates.any(
(template) =>
template.programs.any((program) => program.sourceProgramId == id),
);
}
Future<Program> create({
required String name,
required int defaultRestSeconds,
}) async {
final now = clock.now();
final program = Program(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
defaultRestSeconds: defaultRestSeconds,
);
await programRepository.save(program);
return program;
}
Future<Program> saveConfigured({
String? id,
required String name,
required int defaultRestSeconds,
required List<ProgramExerciseConfig> exercises,
}) async {
final now = clock.now();
final existing = id == null ? null : await programRepository.findById(id);
if (id != null && existing == null) {
throw const DomainException('Program not found.');
}
final programId = id ?? ids.newId();
final metadata =
existing?.metadata.touch(now) ??
EntityMetadata(
id: programId,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
);
final programExercises = <ProgramExercise>[];
for (var index = 0; index < exercises.length; index++) {
final input = exercises[index];
final exerciseMetadata =
input.existingMetadata?.touch(now) ??
_newMetadata(ids, originDeviceId, now);
programExercises.add(
ProgramExercise(
metadata: exerciseMetadata,
programId: programId,
sourceExerciseId: input.sourceExerciseId,
position: index,
exerciseNameSnapshot: input.exerciseNameSnapshot,
exerciseDescriptionSnapshot: input.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
availableTimeSnapshot: input.availableTimeSnapshot,
availableRepsSnapshot: input.availableRepsSnapshot,
availableScoreSnapshot: input.availableScoreSnapshot,
scoreInputModeSnapshot: input.scoreInputModeSnapshot,
scoreLabelSnapshot: input.scoreLabelSnapshot,
scoreUnitSnapshot: input.scoreUnitSnapshot,
setsCount: input.setsCount,
timeEnabled: input.enabledMeasures.contains(WorkoutMeasure.time),
repsEnabled: input.enabledMeasures.contains(WorkoutMeasure.reps),
scoreEnabled: input.enabledMeasures.contains(WorkoutMeasure.score),
targetTimeSeconds: input.targetTimeSeconds,
targetReps: input.targetReps,
targetScore: input.targetScore,
targetScoreTimeMs: input.targetScoreTimeMs,
restSecondsOverride: input.restSecondsOverride,
),
);
}
final program = Program(
metadata: metadata,
name: name,
defaultRestSeconds: defaultRestSeconds,
exercises: programExercises,
);
await programRepository.replaceExercises(program, now);
return program;
}
Future<ProgramExercise> addExercise({
required String programId,
required String exerciseId,
required int position,
required int setsCount,
required Set<WorkoutMeasure> enabledMeasures,
int? targetTimeSeconds,
int? targetReps,
double? targetScore,
int? targetScoreTimeMs,
int? restSecondsOverride,
}) async {
final source = await exerciseRepository.findById(exerciseId);
if (source == null) {
throw const DomainException('Exercise not found.');
}
final now = clock.now();
final programExercise = ProgramExercise.snapshotFromExercise(
metadata: _newMetadata(ids, originDeviceId, now),
programId: programId,
exercise: source,
position: position,
setsCount: setsCount,
enabledMeasures: enabledMeasures,
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
restSecondsOverride: restSecondsOverride,
);
await programRepository.saveExercise(programExercise);
return programExercise;
}
Future<Program> delete(String id) async {
final program = await programRepository.findById(id);
if (program == null) {
throw const DomainException('Program not found.');
}
final now = clock.now();
final deleted = program.copyWith(
metadata: program.metadata.markDeleted(now),
);
final templates = await templateRepository.listActive();
for (final template in templates) {
final keptPrograms = template.programs
.where((templateProgram) => templateProgram.sourceProgramId != id)
.toList();
if (keptPrograms.length == template.programs.length) {
continue;
}
final repositionedPrograms = _repositionWorkoutTemplatePrograms(
keptPrograms,
now,
);
final keptProgramIds = repositionedPrograms
.map((templateProgram) => templateProgram.metadata.id)
.toSet();
await templateRepository.replaceComposition(
WorkoutTemplate(
metadata: template.metadata.touch(now),
name: template.name,
lastStartedAt: template.lastStartedAt,
programs: repositionedPrograms,
overrides: template.overrides
.where(
(override) =>
keptProgramIds.contains(override.workoutTemplateProgramId),
)
.toList(),
),
now,
);
}
await programRepository.save(deleted);
return deleted;
}
}
final class ProgramExerciseConfig {
const ProgramExerciseConfig({
this.existingMetadata,
this.sourceExerciseId,
required this.exerciseNameSnapshot,
this.exerciseDescriptionSnapshot,
this.exerciseImageMediaIdSnapshot,
this.exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
required this.availableScoreSnapshot,
this.scoreInputModeSnapshot = ScoreInputMode.manual,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
required this.enabledMeasures,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
this.restSecondsOverride,
});
final EntityMetadata? existingMetadata;
final String? sourceExerciseId;
final String exerciseNameSnapshot;
final String? exerciseDescriptionSnapshot;
final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
final bool availableScoreSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final int setsCount;
final Set<WorkoutMeasure> enabledMeasures;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final int? restSecondsOverride;
}
final class WorkoutTemplateUseCases {
const WorkoutTemplateUseCases({
required this.templateRepository,
required this.programRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final WorkoutTemplateRepository templateRepository;
final ProgramRepository programRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<WorkoutTemplate?> findById(String id) {
return templateRepository.findById(id);
}
Future<List<WorkoutTemplate>> listActive() {
return templateRepository.listActive();
}
Future<WorkoutTemplate> create({required String name}) async {
final now = clock.now();
final template = WorkoutTemplate(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
);
await templateRepository.save(template);
return template;
}
Future<WorkoutTemplate> saveConfigured({
String? id,
required String name,
required List<WorkoutTemplateProgramConfig> programs,
required List<WorkoutTemplateExerciseOverrideConfig> overrides,
}) async {
final now = clock.now();
final existing = id == null ? null : await templateRepository.findById(id);
if (id != null && existing == null) {
throw const DomainException('Workout template not found.');
}
final templateId = id ?? ids.newId();
final metadata =
existing?.metadata.touch(now) ??
EntityMetadata(
id: templateId,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
);
final templatePrograms = <WorkoutTemplateProgram>[];
final programIdsByClientKey = <String, String>{};
for (var index = 0; index < programs.length; index++) {
final input = programs[index];
final programMetadata =
input.existingMetadata?.touch(now) ??
_newMetadata(ids, originDeviceId, now);
programIdsByClientKey[input.clientKey] = programMetadata.id;
templatePrograms.add(
WorkoutTemplateProgram(
metadata: programMetadata,
workoutTemplateId: templateId,
sourceProgramId: input.sourceProgramId,
position: index,
programNameSnapshot: input.programNameSnapshot,
defaultRestSecondsSnapshot: input.defaultRestSecondsSnapshot,
programSnapshotJson: input.programSnapshotJson,
),
);
}
final templateOverrides = <WorkoutTemplateExerciseOverride>[];
for (final input in overrides) {
final programInput = programs.firstWhere(
(program) => program.clientKey == input.workoutTemplateProgramClientKey,
orElse: () =>
throw const DomainException('Workout template program not found.'),
);
final workoutTemplateProgramId =
programIdsByClientKey[input.workoutTemplateProgramClientKey];
if (workoutTemplateProgramId == null) {
throw const DomainException('Workout template program not found.');
}
_validateOverrideTargets(programInput.programSnapshotJson, input);
templateOverrides.add(
WorkoutTemplateExerciseOverride(
metadata:
input.existingMetadata?.touch(now) ??
_newMetadata(ids, originDeviceId, now),
workoutTemplateProgramId: workoutTemplateProgramId,
snapshotProgramExerciseId: input.snapshotProgramExerciseId,
setsCountOverride: input.setsCountOverride,
targetTimeSecondsOverride: input.targetTimeSecondsOverride,
targetRepsOverride: input.targetRepsOverride,
targetScoreOverride: input.targetScoreOverride,
targetScoreTimeMsOverride: input.targetScoreTimeMsOverride,
),
);
}
final template = WorkoutTemplate(
metadata: metadata,
name: name,
lastStartedAt: existing?.lastStartedAt,
programs: templatePrograms,
overrides: templateOverrides,
);
await templateRepository.replaceComposition(template, now);
return template;
}
Future<WorkoutTemplateProgram> addProgramSnapshot({
required String workoutTemplateId,
required String programId,
required int position,
}) async {
final program = await programRepository.findById(programId);
if (program == null) {
throw const DomainException('Program not found.');
}
final now = clock.now();
final snapshot = WorkoutTemplateProgram.snapshotFromProgram(
metadata: _newMetadata(ids, originDeviceId, now),
workoutTemplateId: workoutTemplateId,
program: program,
position: position,
);
await templateRepository.saveProgram(snapshot);
return snapshot;
}
Future<WorkoutTemplateExerciseOverride> overrideExerciseTargets({
required String workoutTemplateProgramId,
required String snapshotProgramExerciseId,
int? setsCountOverride,
int? targetTimeSecondsOverride,
int? targetRepsOverride,
double? targetScoreOverride,
int? targetScoreTimeMsOverride,
Set<WorkoutMeasure>? attemptedMeasureOverride,
}) async {
if (attemptedMeasureOverride != null) {
throw const DomainException(
'Workout templates cannot override active measures.',
);
}
final now = clock.now();
final override = WorkoutTemplateExerciseOverride(
metadata: _newMetadata(ids, originDeviceId, now),
workoutTemplateProgramId: workoutTemplateProgramId,
snapshotProgramExerciseId: snapshotProgramExerciseId,
setsCountOverride: setsCountOverride,
targetTimeSecondsOverride: targetTimeSecondsOverride,
targetRepsOverride: targetRepsOverride,
targetScoreOverride: targetScoreOverride,
targetScoreTimeMsOverride: targetScoreTimeMsOverride,
);
await templateRepository.saveOverride(override);
return override;
}
Future<WorkoutTemplate> delete(String id) async {
final template = await templateRepository.findById(id);
if (template == null) {
throw const DomainException('Workout template not found.');
}
final now = clock.now();
final deleted = WorkoutTemplate(
metadata: template.metadata.markDeleted(now),
name: template.name,
lastStartedAt: template.lastStartedAt,
);
await templateRepository.save(deleted);
return deleted;
}
}
final class WorkoutTemplateProgramConfig {
const WorkoutTemplateProgramConfig({
required this.clientKey,
this.existingMetadata,
this.sourceProgramId,
required this.programNameSnapshot,
required this.defaultRestSecondsSnapshot,
required this.programSnapshotJson,
});
final String clientKey;
final EntityMetadata? existingMetadata;
final String? sourceProgramId;
final String programNameSnapshot;
final int defaultRestSecondsSnapshot;
final String programSnapshotJson;
}
final class WorkoutTemplateExerciseOverrideConfig {
const WorkoutTemplateExerciseOverrideConfig({
this.existingMetadata,
required this.workoutTemplateProgramClientKey,
required this.snapshotProgramExerciseId,
this.setsCountOverride,
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
this.targetScoreTimeMsOverride,
});
final EntityMetadata? existingMetadata;
final String workoutTemplateProgramClientKey;
final String snapshotProgramExerciseId;
final int? setsCountOverride;
final int? targetTimeSecondsOverride;
final int? targetRepsOverride;
final double? targetScoreOverride;
final int? targetScoreTimeMsOverride;
}
final class ActiveWorkoutSessionUseCases {
const ActiveWorkoutSessionUseCases({
required this.sessionRepository,
required this.templateRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ActiveSessionRepository sessionRepository;
final WorkoutTemplateRepository templateRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<ActiveWorkoutSession?> findOpen() => sessionRepository.findOpen();
Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
final template = await templateRepository.findById(templateId);
if (template == null) {
throw const DomainException('Workout template not found.');
}
final now = clock.now();
final session = ActiveWorkoutSession(
metadata: _newMetadata(ids, originDeviceId, now),
sourceWorkoutTemplateId: templateId,
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({
'templateId': template.metadata.id,
'name': template.name,
'programs': template.programs
.map(
(program) => {
'id': program.metadata.id,
'programNameSnapshot': program.programNameSnapshot,
'programSnapshotJson': program.programSnapshotJson,
},
)
.toList(),
'overrides': template.overrides
.map(
(override) => {
'workoutTemplateProgramId': override.workoutTemplateProgramId,
'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
'setsCountOverride': override.setsCountOverride,
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
'targetRepsOverride': override.targetRepsOverride,
'targetScoreOverride': override.targetScoreOverride,
'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
},
)
.toList(),
}),
);
await sessionRepository.save(session);
return session;
}
Future<ActiveWorkoutSession> startFromHistory(WorkoutHistory history) async {
final snapshot =
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
final resolvedTemplateSnapshotJson =
snapshot['resolvedTemplateSnapshotJson'] as String;
final now = clock.now();
final session = ActiveWorkoutSession(
metadata: _newMetadata(ids, originDeviceId, now),
sourceWorkoutTemplateId: null,
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
);
await sessionRepository.save(session);
return session;
}
Future<ActiveWorkoutSession> pause(String sessionId) async {
final session = await _requiredSession(sessionId);
final now = clock.now();
final runningStopwatches =
(await sessionRepository.listScoreStopwatchStates(
sessionId,
)).where((state) => state.status == ActiveScoreStopwatchStatus.running);
for (final state in runningStopwatches) {
final stopped = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: state.elapsedMillisecondsAt(now),
stoppedAt: now,
);
await sessionRepository.saveScoreStopwatchState(stopped);
}
final paused = session.pause(now);
await sessionRepository.save(paused);
return paused;
}
Future<ActiveWorkoutSession> resume(String sessionId) async {
final session = await _requiredSession(sessionId);
final resumed = session.resume(clock.now());
await sessionRepository.save(resumed);
return resumed;
}
int elapsedActiveMilliseconds(ActiveWorkoutSession session) {
return session.elapsedActiveMillisecondsAt(clock.now());
}
Future<ActiveSetResult> recordSetResult(ActiveSetResult result) async {
await sessionRepository.saveSetResult(result);
return result;
}
Future<ActiveSetResult> recordCurrentSetResult({
required String sessionId,
required String programSnapshotId,
required String exerciseSnapshotId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
ScoreInputMode scoreInputModeSnapshot = ScoreInputMode.manual,
String? scoreLabelSnapshot,
String? scoreUnitSnapshot,
}) async {
final now = clock.now();
final resolvedActualScoreTimeMs = await _resolveStopwatchScoreTimeMs(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
scoreInputMode: scoreInputModeSnapshot,
explicitActualScoreTimeMs: actualScoreTimeMs,
now: now,
);
final result = ActiveSetResult(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programSnapshotId: programSnapshotId,
exerciseSnapshotId: exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
completedAt: now,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: resolvedActualScoreTimeMs,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
status: SetResultStatus.completed,
);
await sessionRepository.saveSetResult(result);
return result;
}
Future<ActiveSetResult> upsertSetResultAtPosition({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required SetResultStatus status,
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
ScoreInputMode? scoreInputModeSnapshot,
String? scoreLabelSnapshot,
String? scoreUnitSnapshot,
String? note,
}) async {
final session = await _requiredSession(sessionId);
_ensureEditablePastPosition(
session: session,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final snapshot = _findSetSnapshot(
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (snapshot == null) {
throw const DomainException(
'Set position not found in session snapshot.',
);
}
final existingResults = await sessionRepository.listSetResults(sessionId);
ActiveSetResult? existing;
for (final result in existingResults) {
if (result.programIndex == programIndex &&
result.exerciseIndex == exerciseIndex &&
result.setIndex == setIndex) {
existing = result;
break;
}
}
final now = clock.now();
final isSkipped = status == SetResultStatus.skipped;
final resolvedScoreInputMode =
scoreInputModeSnapshot ?? snapshot.scoreInputModeSnapshot;
final upserted = ActiveSetResult(
metadata: existing == null
? _newMetadata(ids, originDeviceId, now)
: existing.metadata.touch(now),
activeWorkoutSessionId: sessionId,
programSnapshotId: snapshot.programSnapshotId,
exerciseSnapshotId: snapshot.exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
startedAt: existing?.startedAt,
completedAt: isSkipped ? null : now,
actualTimeMs: isSkipped ? null : actualTimeMs,
actualReps: isSkipped ? null : actualReps,
actualScore: isSkipped ? null : actualScore,
actualScoreTimeMs: isSkipped ? null : actualScoreTimeMs,
scoreInputModeSnapshot: resolvedScoreInputMode,
scoreLabelSnapshot: isSkipped
? null
: scoreLabelSnapshot ?? snapshot.scoreLabelSnapshot,
scoreUnitSnapshot: isSkipped
? null
: scoreUnitSnapshot ?? snapshot.scoreUnitSnapshot,
note: note,
status: status,
);
await sessionRepository.saveSetResult(upserted);
return upserted;
}
Future<ActiveScoreStopwatchState> startScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
await _requiredSession(sessionId);
final now = clock.now();
final existing = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (existing != null) {
if (existing.status == ActiveScoreStopwatchStatus.running) {
return existing;
}
final resumed = existing.copyWith(
metadata: existing.metadata.touch(now),
status: ActiveScoreStopwatchStatus.running,
startedAt: now,
stoppedAt: null,
);
await sessionRepository.saveScoreStopwatchState(resumed);
return resumed;
}
final state = ActiveScoreStopwatchState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
status: ActiveScoreStopwatchStatus.running,
startedAt: now,
accumulatedMs: 0,
);
await sessionRepository.saveScoreStopwatchState(state);
return state;
}
Future<ActiveScoreStopwatchState> stopScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await _requiredStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state.status == ActiveScoreStopwatchStatus.stopped) {
return state;
}
final now = clock.now();
final stopped = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: state.elapsedMillisecondsAt(now),
stoppedAt: now,
);
await sessionRepository.saveScoreStopwatchState(stopped);
return stopped;
}
Future<ActiveScoreStopwatchState> resumeScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return startScoreStopwatch(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
Future<void> resetScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
await sessionRepository.deleteScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
deletedAt: clock.now(),
);
}
Future<ActiveScoreStopwatchState?> findScoreStopwatch({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
int scoreStopwatchElapsedMilliseconds(ActiveScoreStopwatchState state) {
return state.elapsedMillisecondsAt(clock.now());
}
Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
final session = await _requiredSession(sessionId);
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
final results = await sessionRepository.listSetResults(sessionId);
final resultsByPosition = {
for (final result in results)
_positionKey(
result.programIndex,
result.exerciseIndex,
result.setIndex,
): result,
};
return snapshots.map((snapshot) {
final result =
resultsByPosition[_positionKey(
snapshot.programIndex,
snapshot.exerciseIndex,
snapshot.setIndex,
)];
return SetResultPositionState(
programIndex: snapshot.programIndex,
exerciseIndex: snapshot.exerciseIndex,
setIndex: snapshot.setIndex,
status: result == null
? SetPositionStatus.pending
: switch (result.status) {
SetResultStatus.completed => SetPositionStatus.completed,
SetResultStatus.skipped => SetPositionStatus.skipped,
},
result: result,
);
}).toList();
}
Future<ActiveWorkoutSession> updateProgress({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final session = await _requiredSession(sessionId);
final updated = session.copyWith(
currentProgramIndex: programIndex,
currentExerciseIndex: exerciseIndex,
currentSetIndex: setIndex,
metadata: session.metadata.touch(clock.now()),
);
await sessionRepository.save(updated);
return updated;
}
Future<ActiveWorkoutSession> quitAndSave(String sessionId) async {
final session = await _requiredSession(sessionId);
final now = clock.now();
final saved = session.copyWith(
status: ActiveWorkoutStatus.savedExit,
lastPersistedAt: now,
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
metadata: session.metadata.touch(now),
);
await sessionRepository.save(saved);
return saved;
}
Future<ActiveWorkoutSession> abandon(String sessionId) async {
final session = await _requiredSession(sessionId);
final now = clock.now();
final abandoned = session.copyWith(
status: ActiveWorkoutStatus.abandoned,
endedAt: now,
lastPersistedAt: now,
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
metadata: session.metadata.touch(now),
);
await sessionRepository.save(abandoned);
return abandoned;
}
Future<ActiveWorkoutSession> complete(String sessionId) async {
final session = await _requiredSession(sessionId);
final now = clock.now();
final completed = session.copyWith(
status: ActiveWorkoutStatus.completed,
endedAt: now,
lastPersistedAt: now,
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
metadata: session.metadata.touch(now),
);
await sessionRepository.save(completed);
return completed;
}
Future<ActiveRestState> startRestAfterSet({
required String sessionId,
required int afterProgramIndex,
required int afterExerciseIndex,
required int afterSetIndex,
required int plannedRestSeconds,
int? adjustedRestSeconds,
}) async {
final now = clock.now();
final rest = ActiveRestState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
afterProgramIndex: afterProgramIndex,
afterExerciseIndex: afterExerciseIndex,
afterSetIndex: afterSetIndex,
plannedRestSeconds: plannedRestSeconds,
adjustedRestSeconds: adjustedRestSeconds ?? plannedRestSeconds,
startedAt: now,
);
await sessionRepository.saveRestState(rest);
return rest;
}
Future<ActiveRestState> adjustRestSeconds({
required String restStateId,
required int deltaSeconds,
}) async {
final rest = await sessionRepository.findRestStateById(restStateId);
if (rest == null) {
throw const DomainException('Active rest state not found.');
}
final now = clock.now();
final adjusted = rest.copyWith(
metadata: rest.metadata.touch(now),
adjustedRestSeconds: (rest.adjustedRestSeconds + deltaSeconds).clamp(
0,
1 << 31,
),
);
await sessionRepository.saveRestState(adjusted);
return adjusted;
}
Future<ActiveRestState> skipRest({required String restStateId}) async {
final rest = await sessionRepository.findRestStateById(restStateId);
if (rest == null) {
throw const DomainException('Active rest state not found.');
}
final now = clock.now();
final skipped = rest.copyWith(
metadata: rest.metadata.touch(now),
skippedAt: now,
);
await sessionRepository.saveRestState(skipped);
return skipped;
}
Future<ActiveRestState?> findActiveRest({required String sessionId}) async {
final restStates = await sessionRepository.listRestStates(sessionId);
final active =
restStates
.where((rest) => rest.endedAt == null && rest.skippedAt == null)
.toList()
..sort((left, right) => right.startedAt.compareTo(left.startedAt));
return active.isEmpty ? null : active.first;
}
Future<ActiveWorkoutSession> _requiredSession(String sessionId) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {
throw const DomainException('Active workout session not found.');
}
return session;
}
Future<ActiveScoreStopwatchState> _requiredStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
throw const DomainException('Active score stopwatch state not found.');
}
return state;
}
Future<int?> _resolveStopwatchScoreTimeMs({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required ScoreInputMode scoreInputMode,
required int? explicitActualScoreTimeMs,
required DateTime now,
}) async {
if (scoreInputMode != ScoreInputMode.stopwatch) {
return explicitActualScoreTimeMs;
}
final state = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
return explicitActualScoreTimeMs;
}
if (state.status == ActiveScoreStopwatchStatus.stopped) {
return explicitActualScoreTimeMs ?? state.accumulatedMs;
}
final stopped = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: state.elapsedMillisecondsAt(now),
stoppedAt: now,
);
await sessionRepository.saveScoreStopwatchState(stopped);
return explicitActualScoreTimeMs ?? stopped.accumulatedMs;
}
}
final class CloseWorkoutSessionUseCase {
const CloseWorkoutSessionUseCase({
required this.sessionRepository,
required this.historyRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ActiveSessionRepository sessionRepository;
final WorkoutHistoryRepository historyRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<WorkoutHistory> close({
required String sessionId,
required String nameSnapshot,
required bool completed,
}) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {
throw const DomainException('Active workout session not found.');
}
final now = clock.now();
final results = await sessionRepository.listSetResults(sessionId);
final historyId = ids.newId();
final historyResults = _historyResultsFromActiveResults(
historyId: historyId,
results: results,
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
now: now,
ids: ids,
originDeviceId: originDeviceId,
);
final history = WorkoutHistory(
metadata: EntityMetadata(
id: historyId,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
),
sourceWorkoutTemplateId: session.sourceWorkoutTemplateId,
sourceActiveWorkoutSessionId: session.metadata.id,
nameSnapshot: nameSnapshot,
startedAt: session.startedAt,
endedAt: now,
totalActiveMs: session.elapsedActiveMillisecondsAt(now),
completed: completed,
historySnapshotJson: jsonEncode({
'sessionId': session.metadata.id,
'resolvedTemplateSnapshotJson': session.resolvedTemplateSnapshotJson,
'results': results
.map(
(result) => {
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'status': result.status.name,
},
)
.toList(),
}),
results: historyResults,
);
await historyRepository.save(history);
return history;
}
}
final class WorkoutHistoryUseCases {
const WorkoutHistoryUseCases({required this.repository, required this.clock});
final WorkoutHistoryRepository repository;
final Clock clock;
Future<WorkoutHistory?> findById(String id) => repository.findById(id);
Future<List<WorkoutHistory>> listActive() => repository.listActive();
Future<void> delete(String id) => repository.delete(id, clock.now());
}
enum SetPositionStatus { pending, completed, skipped }
final class SetResultPositionState {
const SetResultPositionState({
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.status,
this.result,
});
final int programIndex;
final int exerciseIndex;
final int setIndex;
final SetPositionStatus status;
final ActiveSetResult? result;
}
List<ProgramExercise> _repositionProgramExercises(
List<ProgramExercise> exercises,
DateTime now,
) {
return [
for (var index = 0; index < exercises.length; index++)
ProgramExercise(
metadata: exercises[index].metadata.touch(now),
programId: exercises[index].programId,
sourceExerciseId: exercises[index].sourceExerciseId,
position: index,
exerciseNameSnapshot: exercises[index].exerciseNameSnapshot,
exerciseDescriptionSnapshot:
exercises[index].exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot:
exercises[index].exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot:
exercises[index].exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot:
exercises[index].exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot,
availableTimeSnapshot: exercises[index].availableTimeSnapshot,
availableRepsSnapshot: exercises[index].availableRepsSnapshot,
availableScoreSnapshot: exercises[index].availableScoreSnapshot,
scoreInputModeSnapshot: exercises[index].scoreInputModeSnapshot,
scoreLabelSnapshot: exercises[index].scoreLabelSnapshot,
scoreUnitSnapshot: exercises[index].scoreUnitSnapshot,
setsCount: exercises[index].setsCount,
timeEnabled: exercises[index].timeEnabled,
repsEnabled: exercises[index].repsEnabled,
scoreEnabled: exercises[index].scoreEnabled,
targetTimeSeconds: exercises[index].targetTimeSeconds,
targetReps: exercises[index].targetReps,
targetScore: exercises[index].targetScore,
targetScoreTimeMs: exercises[index].targetScoreTimeMs,
restSecondsOverride: exercises[index].restSecondsOverride,
),
];
}
List<WorkoutTemplateProgram> _repositionWorkoutTemplatePrograms(
List<WorkoutTemplateProgram> programs,
DateTime now,
) {
return [
for (var index = 0; index < programs.length; index++)
WorkoutTemplateProgram(
metadata: programs[index].metadata.touch(now),
workoutTemplateId: programs[index].workoutTemplateId,
sourceProgramId: programs[index].sourceProgramId,
position: index,
programNameSnapshot: programs[index].programNameSnapshot,
defaultRestSecondsSnapshot: programs[index].defaultRestSecondsSnapshot,
programSnapshotJson: programs[index].programSnapshotJson,
),
];
}
List<String> _resolveExerciseImageIds(
String? imageMediaId,
List<String> imageMediaIds,
) {
if (imageMediaId != null) {
return [imageMediaId];
}
return imageMediaIds;
}
List<String> _appendExerciseImage(List<String> currentIds, String mediaId) {
if (currentIds.contains(mediaId)) {
return currentIds;
}
if (currentIds.length >= 5) {
throw const DomainException('An exercise cannot have more than 5 images.');
}
return [...currentIds, mediaId];
}
bool _sameImageSet(List<String> left, List<String> right) {
if (left.length != right.length) {
return false;
}
return left.toSet().containsAll(right) && right.toSet().containsAll(left);
}
void _validateExerciseDefaultTargets({
required bool hasTimeMeasure,
required bool hasRepsMeasure,
required bool hasScoreMeasure,
required ScoreInputMode scoreInputMode,
required int? defaultTargetTimeSeconds,
required int? defaultTargetReps,
required double? defaultTargetScore,
required int? defaultTargetScoreTimeMs,
}) {
if (hasTimeMeasure &&
(defaultTargetTimeSeconds == null || defaultTargetTimeSeconds <= 0)) {
throw const DomainException(
'Default target time seconds must be positive.',
);
}
if (hasRepsMeasure && (defaultTargetReps == null || defaultTargetReps <= 0)) {
throw const DomainException('Default target reps must be positive.');
}
if (hasScoreMeasure &&
scoreInputMode == ScoreInputMode.manual &&
(defaultTargetScore == null || defaultTargetScore <= 0)) {
throw const DomainException('Default target score must be positive.');
}
if (defaultTargetScore != null &&
(!hasScoreMeasure || scoreInputMode != ScoreInputMode.manual)) {
throw const DomainException(
'Default target score requires manual score mode.',
);
}
if (defaultTargetScoreTimeMs != null && defaultTargetScoreTimeMs <= 0) {
throw const DomainException(
'Default target score time ms must be positive.',
);
}
if (defaultTargetScoreTimeMs != null &&
(!hasScoreMeasure || scoreInputMode != ScoreInputMode.stopwatch)) {
throw const DomainException(
'Default target score time ms requires stopwatch score mode.',
);
}
}
void _validateOverrideTargets(
String programSnapshotJson,
WorkoutTemplateExerciseOverrideConfig input,
) {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises = (snapshot['exercises'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final exercise = exercises.cast<Map<String, dynamic>?>().firstWhere(
(item) =>
item?['id'] == input.snapshotProgramExerciseId ||
item?['snapshotProgramExerciseId'] == input.snapshotProgramExerciseId,
orElse: () => null,
);
if (exercise == null) {
throw const DomainException('Snapshot exercise not found.');
}
if (input.targetTimeSecondsOverride != null &&
exercise['timeEnabled'] != true) {
throw const DomainException('Cannot override inactive time target.');
}
if (input.targetRepsOverride != null && exercise['repsEnabled'] != true) {
throw const DomainException('Cannot override inactive reps target.');
}
if (input.targetScoreOverride != null && exercise['scoreEnabled'] != true) {
throw const DomainException('Cannot override inactive score target.');
}
if (input.targetScoreTimeMsOverride != null &&
exercise['scoreEnabled'] != true) {
throw const DomainException('Cannot override inactive score target.');
}
final mode = _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
if (input.targetScoreOverride != null && mode != ScoreInputMode.manual) {
throw const DomainException(
'Cannot override manual score target on stopwatch score mode.',
);
}
if (input.targetScoreTimeMsOverride != null &&
mode != ScoreInputMode.stopwatch) {
throw const DomainException(
'Cannot override stopwatch score target on manual score mode.',
);
}
}
void _ensureEditablePastPosition({
required ActiveWorkoutSession session,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
final comparison = _comparePositions(
programIndex,
exerciseIndex,
setIndex,
session.currentProgramIndex,
session.currentExerciseIndex,
session.currentSetIndex,
);
if (comparison >= 0) {
throw const DomainException(
'Only past set results can be edited outside the normal execution flow.',
);
}
}
int _comparePositions(
int leftProgram,
int leftExercise,
int leftSet,
int rightProgram,
int rightExercise,
int rightSet,
) {
final programComparison = leftProgram.compareTo(rightProgram);
if (programComparison != 0) {
return programComparison;
}
final exerciseComparison = leftExercise.compareTo(rightExercise);
if (exerciseComparison != 0) {
return exerciseComparison;
}
return leftSet.compareTo(rightSet);
}
String _positionKey(int programIndex, int exerciseIndex, int setIndex) {
return '$programIndex:$exerciseIndex:$setIndex';
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
return switch (value) {
'stopwatch' => ScoreInputMode.stopwatch,
_ => ScoreInputMode.manual,
};
}
_SetPositionSnapshot? _findSetSnapshot({
required String resolvedTemplateSnapshotJson,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
for (final snapshot in _listSetSnapshots(resolvedTemplateSnapshotJson)) {
if (snapshot.programIndex == programIndex &&
snapshot.exerciseIndex == exerciseIndex &&
snapshot.setIndex == setIndex) {
return snapshot;
}
}
return null;
}
List<_SetPositionSnapshot> _listSetSnapshots(
String resolvedTemplateSnapshotJson,
) {
final decoded =
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
final snapshots = <_SetPositionSnapshot>[];
for (var programIndex = 0; programIndex < programs.length; programIndex++) {
final program = programs[programIndex] as Map<String, dynamic>;
final programSnapshotId =
program['id'] as String? ?? 'program-$programIndex';
final programSnapshotJson = program['programSnapshotJson'] as String?;
if (programSnapshotJson == null) {
continue;
}
final programSnapshot =
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises =
(programSnapshot['exercises'] as List<dynamic>? ?? const []);
for (
var exerciseIndex = 0;
exerciseIndex < exercises.length;
exerciseIndex++
) {
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
final exerciseSnapshotId =
exercise['id'] as String? ?? 'exercise-$exerciseIndex';
final setsCount = exercise['setsCount'] as int? ?? 0;
for (var setIndex = 0; setIndex < setsCount; setIndex++) {
snapshots.add(
_SetPositionSnapshot(
programSnapshotId: programSnapshotId,
exerciseSnapshotId: exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
),
);
}
}
}
return snapshots;
}
final class _SetPositionSnapshot {
const _SetPositionSnapshot({
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.scoreInputModeSnapshot,
});
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
}
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
required String historyId,
required List<ActiveSetResult> results,
required String resolvedTemplateSnapshotJson,
required DateTime now,
required IdGenerator ids,
required String originDeviceId,
}) {
final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson);
return results.map((result) {
final snapshot = snapshots[result.exerciseSnapshotId];
return WorkoutHistorySetResult(
metadata: _newMetadata(ids, originDeviceId, now),
workoutHistoryId: historyId,
programSnapshotId: result.programSnapshotId,
exerciseSnapshotId: result.exerciseSnapshotId,
programIndex: result.programIndex,
exerciseIndex: result.exerciseIndex,
setIndex: result.setIndex,
programNameSnapshot:
snapshot?.programNameSnapshot ?? result.programSnapshotId,
exerciseNameSnapshot:
snapshot?.exerciseNameSnapshot ?? result.exerciseSnapshotId,
timeEnabledSnapshot: snapshot?.timeEnabled ?? result.actualTimeMs != null,
repsEnabledSnapshot: snapshot?.repsEnabled ?? result.actualReps != null,
scoreEnabledSnapshot:
snapshot?.scoreEnabled ??
(result.actualScore != null || result.actualScoreTimeMs != null),
targetTimeSecondsSnapshot: snapshot?.targetTimeSeconds,
targetRepsSnapshot: snapshot?.targetReps,
targetScoreSnapshot: snapshot?.targetScore,
targetScoreTimeMsSnapshot: snapshot?.targetScoreTimeMs,
actualTimeMs: result.actualTimeMs,
actualReps: result.actualReps,
actualScore: result.actualScore,
actualScoreTimeMs: result.actualScoreTimeMs,
scoreInputModeSnapshot:
snapshot?.scoreInputModeSnapshot ?? result.scoreInputModeSnapshot,
scoreLabelSnapshot:
result.scoreLabelSnapshot ?? snapshot?.scoreLabelSnapshot,
scoreUnitSnapshot:
result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot,
startedAt: result.startedAt,
completedAt: result.completedAt,
status: result.status,
);
}).toList();
}
Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
String resolvedTemplateSnapshotJson,
) {
final decoded =
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
final snapshots = <String, _ResolvedExerciseSnapshot>{};
for (final program in programs.cast<Map<String, dynamic>>()) {
final programName = program['programNameSnapshot'] as String? ?? '';
final programSnapshotJson = program['programSnapshotJson'] as String?;
if (programSnapshotJson == null) {
continue;
}
final programSnapshot =
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises =
(programSnapshot['exercises'] as List<dynamic>? ?? const []);
for (final exercise in exercises.cast<Map<String, dynamic>>()) {
final id = exercise['id'] as String?;
if (id == null) {
continue;
}
snapshots[id] = _ResolvedExerciseSnapshot(
programNameSnapshot: programName,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
timeEnabled: exercise['timeEnabled'] == true,
repsEnabled: exercise['repsEnabled'] == true,
scoreEnabled: exercise['scoreEnabled'] == true,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
);
}
}
return snapshots;
}
final class _ResolvedExerciseSnapshot {
const _ResolvedExerciseSnapshot({
required this.programNameSnapshot,
required this.exerciseNameSnapshot,
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
required this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
});
final String programNameSnapshot;
final String exerciseNameSnapshot;
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
}
EntityMetadata _newMetadata(
IdGenerator ids,
String originDeviceId,
DateTime now,
) {
return EntityMetadata(
id: ids.newId(),
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
);
}