Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les entités du domaine, les ports et use cases pour poser les fondations du score chronométré. build_runner OK, flutter analyze propre, 39/39 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1590 lines
52 KiB
Dart
1590 lines
52 KiB
Dart
import 'dart:convert';
|
|
|
|
import '../domain/domain.dart';
|
|
import 'ports.dart';
|
|
|
|
final class ExerciseUseCases {
|
|
const ExerciseUseCases({
|
|
required this.repository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final ExerciseRepository repository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<Exercise> create({
|
|
required String name,
|
|
String? description,
|
|
String? imageMediaId,
|
|
String? videoMediaId,
|
|
required bool hasTimeMeasure,
|
|
required bool hasRepsMeasure,
|
|
required bool hasScoreMeasure,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
String? scoreLabel,
|
|
String? scoreUnit,
|
|
}) async {
|
|
final now = clock.now();
|
|
final exercise = Exercise(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
name: name,
|
|
description: description,
|
|
imageMediaId: imageMediaId,
|
|
videoMediaId: videoMediaId,
|
|
hasTimeMeasure: hasTimeMeasure,
|
|
hasRepsMeasure: hasRepsMeasure,
|
|
hasScoreMeasure: hasScoreMeasure,
|
|
scoreInputMode: scoreInputMode,
|
|
scoreLabel: scoreLabel,
|
|
scoreUnit: scoreUnit,
|
|
);
|
|
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,
|
|
String? videoMediaId,
|
|
required bool hasTimeMeasure,
|
|
required bool hasRepsMeasure,
|
|
required bool hasScoreMeasure,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
String? scoreLabel,
|
|
String? scoreUnit,
|
|
}) async {
|
|
final exercise = await repository.findById(id);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final updated = exercise.copyWith(
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
name: name,
|
|
description: description,
|
|
imageMediaId: imageMediaId,
|
|
videoMediaId: videoMediaId,
|
|
hasTimeMeasure: hasTimeMeasure,
|
|
hasRepsMeasure: hasRepsMeasure,
|
|
hasScoreMeasure: hasScoreMeasure,
|
|
scoreInputMode: scoreInputMode,
|
|
scoreLabel: scoreLabel,
|
|
scoreUnit: scoreUnit,
|
|
);
|
|
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(
|
|
imageMediaId: 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;
|
|
}
|
|
}
|
|
|
|
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(
|
|
imageMediaId: 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.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final ProgramRepository programRepository;
|
|
final ExerciseRepository exerciseRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<Program?> findById(String id) => programRepository.findById(id);
|
|
|
|
Future<List<Program>> listActive() => programRepository.listActive();
|
|
|
|
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,
|
|
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;
|
|
}
|
|
}
|
|
|
|
final class ProgramExerciseConfig {
|
|
const ProgramExerciseConfig({
|
|
this.existingMetadata,
|
|
this.sourceExerciseId,
|
|
required this.exerciseNameSnapshot,
|
|
this.exerciseDescriptionSnapshot,
|
|
this.exerciseImageMediaIdSnapshot,
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|