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 create({ required String name, String? description, String? imageMediaId, String? videoMediaId, required bool hasTimeMeasure, required bool hasRepsMeasure, required bool hasScoreMeasure, 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, scoreLabel: scoreLabel, scoreUnit: scoreUnit, ); await repository.save(exercise); return exercise; } Future save(Exercise exercise) => repository.save(exercise); Future> listActive() => repository.listActive(); Future isUsedInPrograms(String id) { return repository.isReferencedByProgram(id); } Future update({ required String id, required String name, String? description, String? imageMediaId, String? videoMediaId, required bool hasTimeMeasure, required bool hasRepsMeasure, required bool hasScoreMeasure, 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, scoreLabel: scoreLabel, scoreUnit: scoreUnit, ); await repository.save(updated); return updated; } Future 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 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 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 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 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 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 findById(String id) => programRepository.findById(id); Future> listActive() => programRepository.listActive(); Future 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 saveConfigured({ String? id, required String name, required int defaultRestSeconds, required List 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 = []; 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, 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, restSecondsOverride: input.restSecondsOverride, ), ); } final program = Program( metadata: metadata, name: name, defaultRestSeconds: defaultRestSeconds, exercises: programExercises, ); await programRepository.replaceExercises(program, now); return program; } Future addExercise({ required String programId, required String exerciseId, required int position, required int setsCount, required Set enabledMeasures, int? targetTimeSeconds, int? targetReps, double? targetScore, 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, 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.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.setsCount, required this.enabledMeasures, this.targetTimeSeconds, this.targetReps, this.targetScore, 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 String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final int setsCount; final Set enabledMeasures; final int? targetTimeSeconds; final int? targetReps; final double? targetScore; 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 findById(String id) { return templateRepository.findById(id); } Future> listActive() { return templateRepository.listActive(); } Future 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 saveConfigured({ String? id, required String name, required List programs, required List 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 = []; final programIdsByClientKey = {}; 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 = []; for (final input in overrides) { final workoutTemplateProgramId = programIdsByClientKey[input.workoutTemplateProgramClientKey]; if (workoutTemplateProgramId == null) { throw const DomainException('Workout template program not found.'); } 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, ), ); } final template = WorkoutTemplate( metadata: metadata, name: name, lastStartedAt: existing?.lastStartedAt, programs: templatePrograms, overrides: templateOverrides, ); await templateRepository.replaceComposition(template, now); return template; } Future 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 overrideExerciseTargets({ required String workoutTemplateProgramId, required String snapshotProgramExerciseId, int? setsCountOverride, int? targetTimeSecondsOverride, int? targetRepsOverride, double? targetScoreOverride, Set? 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, ); 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, }); final EntityMetadata? existingMetadata; final String workoutTemplateProgramClientKey; final String snapshotProgramExerciseId; final int? setsCountOverride; final int? targetTimeSecondsOverride; final int? targetRepsOverride; final double? targetScoreOverride; } 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 findOpen() => sessionRepository.findOpen(); Future 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, }, ) .toList(), }), ); await sessionRepository.save(session); return session; } Future pause(String sessionId) async { final session = await _requiredSession(sessionId); final paused = session.pause(clock.now()); await sessionRepository.save(paused); return paused; } Future 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 recordSetResult(ActiveSetResult result) async { await sessionRepository.saveSetResult(result); return result; } Future 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, String? scoreLabelSnapshot, String? scoreUnitSnapshot, }) async { final now = clock.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, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, ); await sessionRepository.saveSetResult(result); return result; } Future 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 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 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 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 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 _requiredSession(String sessionId) async { final session = await sessionRepository.findById(sessionId); if (session == null) { throw const DomainException('Active workout session not found.'); } return session; } } 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 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 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, 'scoreLabelSnapshot': result.scoreLabelSnapshot, 'scoreUnitSnapshot': result.scoreUnitSnapshot, }, ) .toList(), }), ); await historyRepository.save(history); return history; } } EntityMetadata _newMetadata( IdGenerator ids, String originDeviceId, DateTime now, ) { return EntityMetadata( id: ids.newId(), createdAt: now, updatedAt: now, originDeviceId: originDeviceId, ); }