feat(application): couche application avec use cases et repositories
Ajoute les entités du domaine (domain/entities.dart), les ports de repository (application/ports.dart), les use cases (application/use_cases.dart) et leur implémentation Drift (infrastructure/local/drift_repositories.dart). flutter analyze propre, tests unitaires verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -3,3 +3,6 @@
|
||||
/// This layer owns use cases and ports. It may depend on domain, but concrete
|
||||
/// adapters are wired only from the composition root.
|
||||
library;
|
||||
|
||||
export 'ports.dart';
|
||||
export 'use_cases.dart';
|
||||
|
||||
49
lib/application/ports.dart
Normal file
49
lib/application/ports.dart
Normal file
@ -0,0 +1,49 @@
|
||||
import '../domain/domain.dart';
|
||||
|
||||
abstract interface class Clock {
|
||||
DateTime now();
|
||||
}
|
||||
|
||||
final class SystemClock implements Clock {
|
||||
const SystemClock();
|
||||
|
||||
@override
|
||||
DateTime now() => DateTime.now().toUtc();
|
||||
}
|
||||
|
||||
abstract interface class IdGenerator {
|
||||
String newId();
|
||||
}
|
||||
|
||||
abstract interface class ExerciseRepository {
|
||||
Future<Exercise?> findById(String id);
|
||||
Future<List<Exercise>> listActive();
|
||||
Future<void> save(Exercise exercise);
|
||||
}
|
||||
|
||||
abstract interface class ProgramRepository {
|
||||
Future<Program?> findById(String id);
|
||||
Future<void> save(Program program);
|
||||
Future<void> saveExercise(ProgramExercise exercise);
|
||||
}
|
||||
|
||||
abstract interface class WorkoutTemplateRepository {
|
||||
Future<WorkoutTemplate?> findById(String id);
|
||||
Future<void> save(WorkoutTemplate template);
|
||||
Future<void> saveProgram(WorkoutTemplateProgram program);
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override);
|
||||
}
|
||||
|
||||
abstract interface class ActiveSessionRepository {
|
||||
Future<ActiveWorkoutSession?> findById(String id);
|
||||
Future<void> save(ActiveWorkoutSession session);
|
||||
Future<void> saveSetResult(ActiveSetResult result);
|
||||
Future<void> saveRestState(ActiveRestState restState);
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId);
|
||||
Future<List<ActiveRestState>> listRestStates(String sessionId);
|
||||
}
|
||||
|
||||
abstract interface class WorkoutHistoryRepository {
|
||||
Future<void> save(WorkoutHistory history);
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
||||
}
|
||||
390
lib/application/use_cases.dart
Normal file
390
lib/application/use_cases.dart
Normal file
@ -0,0 +1,390 @@
|
||||
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,
|
||||
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<void> save(Exercise exercise) => repository.save(exercise);
|
||||
|
||||
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 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> 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<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? 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 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> 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<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,
|
||||
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,
|
||||
);
|
||||
await templateRepository.saveOverride(override);
|
||||
return override;
|
||||
}
|
||||
}
|
||||
|
||||
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> 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<ActiveWorkoutSession> pause(String sessionId) async {
|
||||
final session = await _requiredSession(sessionId);
|
||||
final paused = session.pause(clock.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<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<ActiveWorkoutSession> _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<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 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,
|
||||
);
|
||||
}
|
||||
@ -2,3 +2,5 @@
|
||||
///
|
||||
/// Keep this layer free from Flutter, Drift, platform APIs, and IO.
|
||||
library;
|
||||
|
||||
export 'entities.dart';
|
||||
|
||||
742
lib/domain/entities.dart
Normal file
742
lib/domain/entities.dart
Normal file
@ -0,0 +1,742 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum SyncState { localOnly, dirty, synced, deleted }
|
||||
|
||||
enum MediaKind { image, video }
|
||||
|
||||
enum WorkoutMeasure { time, reps, score }
|
||||
|
||||
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
|
||||
|
||||
final class DomainException implements Exception {
|
||||
const DomainException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
final class EntityMetadata {
|
||||
const EntityMetadata({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.originDeviceId,
|
||||
this.deletedAt,
|
||||
this.schemaVersion = 1,
|
||||
this.syncState = SyncState.dirty,
|
||||
this.localRevision = 0,
|
||||
this.futureOwnerProfileId,
|
||||
this.lastSyncedAt,
|
||||
this.remoteRevision,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final int schemaVersion;
|
||||
final SyncState syncState;
|
||||
final int localRevision;
|
||||
final String originDeviceId;
|
||||
final String? futureOwnerProfileId;
|
||||
final DateTime? lastSyncedAt;
|
||||
final String? remoteRevision;
|
||||
|
||||
EntityMetadata touch(DateTime now) {
|
||||
return copyWith(updatedAt: now, localRevision: localRevision + 1);
|
||||
}
|
||||
|
||||
EntityMetadata markDeleted(DateTime now) {
|
||||
return copyWith(
|
||||
updatedAt: now,
|
||||
deletedAt: now,
|
||||
syncState: SyncState.deleted,
|
||||
localRevision: localRevision + 1,
|
||||
);
|
||||
}
|
||||
|
||||
EntityMetadata copyWith({
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? deletedAt,
|
||||
int? schemaVersion,
|
||||
SyncState? syncState,
|
||||
int? localRevision,
|
||||
String? originDeviceId,
|
||||
String? futureOwnerProfileId,
|
||||
DateTime? lastSyncedAt,
|
||||
String? remoteRevision,
|
||||
}) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
schemaVersion: schemaVersion ?? this.schemaVersion,
|
||||
syncState: syncState ?? this.syncState,
|
||||
localRevision: localRevision ?? this.localRevision,
|
||||
originDeviceId: originDeviceId ?? this.originDeviceId,
|
||||
futureOwnerProfileId: futureOwnerProfileId ?? this.futureOwnerProfileId,
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
remoteRevision: remoteRevision ?? this.remoteRevision,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class MediaAsset {
|
||||
const MediaAsset({
|
||||
required this.metadata,
|
||||
required this.kind,
|
||||
required this.localUri,
|
||||
this.mimeType,
|
||||
this.sizeBytes,
|
||||
this.width,
|
||||
this.height,
|
||||
this.durationMs,
|
||||
this.checksum,
|
||||
this.remoteUri,
|
||||
this.thumbnailLocalUri,
|
||||
});
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final MediaKind kind;
|
||||
final String localUri;
|
||||
final String? mimeType;
|
||||
final int? sizeBytes;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? durationMs;
|
||||
final String? checksum;
|
||||
final String? remoteUri;
|
||||
final String? thumbnailLocalUri;
|
||||
}
|
||||
|
||||
final class Exercise {
|
||||
Exercise({
|
||||
required this.metadata,
|
||||
required String name,
|
||||
this.description,
|
||||
this.imageMediaId,
|
||||
this.videoMediaId,
|
||||
required this.hasTimeMeasure,
|
||||
required this.hasRepsMeasure,
|
||||
required this.hasScoreMeasure,
|
||||
this.scoreLabel,
|
||||
this.scoreUnit,
|
||||
this.archivedAt,
|
||||
}) : name = _nonBlank(name, 'Exercise name') {
|
||||
_requireAtLeastOneMeasure(
|
||||
hasTime: hasTimeMeasure,
|
||||
hasReps: hasRepsMeasure,
|
||||
hasScore: hasScoreMeasure,
|
||||
);
|
||||
if (hasScoreMeasure) {
|
||||
_nonBlank(scoreLabel, 'Score label');
|
||||
_nonBlank(scoreUnit, 'Score unit');
|
||||
}
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String? imageMediaId;
|
||||
final String? videoMediaId;
|
||||
final bool hasTimeMeasure;
|
||||
final bool hasRepsMeasure;
|
||||
final bool hasScoreMeasure;
|
||||
final String? scoreLabel;
|
||||
final String? scoreUnit;
|
||||
final DateTime? archivedAt;
|
||||
|
||||
Set<WorkoutMeasure> get availableMeasures => {
|
||||
if (hasTimeMeasure) WorkoutMeasure.time,
|
||||
if (hasRepsMeasure) WorkoutMeasure.reps,
|
||||
if (hasScoreMeasure) WorkoutMeasure.score,
|
||||
};
|
||||
|
||||
Exercise archive(DateTime now) {
|
||||
return copyWith(archivedAt: now, metadata: metadata.touch(now));
|
||||
}
|
||||
|
||||
Exercise copyWith({
|
||||
EntityMetadata? metadata,
|
||||
String? name,
|
||||
String? description,
|
||||
String? imageMediaId,
|
||||
String? videoMediaId,
|
||||
bool? hasTimeMeasure,
|
||||
bool? hasRepsMeasure,
|
||||
bool? hasScoreMeasure,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
DateTime? archivedAt,
|
||||
}) {
|
||||
return Exercise(
|
||||
metadata: metadata ?? this.metadata,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
imageMediaId: imageMediaId ?? this.imageMediaId,
|
||||
videoMediaId: videoMediaId ?? this.videoMediaId,
|
||||
hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure,
|
||||
hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure,
|
||||
hasScoreMeasure: hasScoreMeasure ?? this.hasScoreMeasure,
|
||||
scoreLabel: scoreLabel ?? this.scoreLabel,
|
||||
scoreUnit: scoreUnit ?? this.scoreUnit,
|
||||
archivedAt: archivedAt ?? this.archivedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class Program {
|
||||
Program({
|
||||
required this.metadata,
|
||||
required String name,
|
||||
required this.defaultRestSeconds,
|
||||
this.exercises = const [],
|
||||
}) : name = _nonBlank(name, 'Program name') {
|
||||
_requireNonNegative(defaultRestSeconds, 'Default rest seconds');
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String name;
|
||||
final int defaultRestSeconds;
|
||||
final List<ProgramExercise> exercises;
|
||||
|
||||
Program copyWith({
|
||||
List<ProgramExercise>? exercises,
|
||||
EntityMetadata? metadata,
|
||||
}) {
|
||||
return Program(
|
||||
metadata: metadata ?? this.metadata,
|
||||
name: name,
|
||||
defaultRestSeconds: defaultRestSeconds,
|
||||
exercises: exercises ?? this.exercises,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class ProgramExercise {
|
||||
ProgramExercise({
|
||||
required this.metadata,
|
||||
required this.programId,
|
||||
this.sourceExerciseId,
|
||||
required this.position,
|
||||
required String exerciseNameSnapshot,
|
||||
this.exerciseDescriptionSnapshot,
|
||||
this.exerciseImageMediaIdSnapshot,
|
||||
this.exerciseVideoMediaIdSnapshot,
|
||||
this.exerciseArchivedSnapshot = false,
|
||||
required this.availableTimeSnapshot,
|
||||
required this.availableRepsSnapshot,
|
||||
required this.availableScoreSnapshot,
|
||||
this.scoreLabelSnapshot,
|
||||
this.scoreUnitSnapshot,
|
||||
required this.setsCount,
|
||||
required this.timeEnabled,
|
||||
required this.repsEnabled,
|
||||
required this.scoreEnabled,
|
||||
this.targetTimeSeconds,
|
||||
this.targetReps,
|
||||
this.targetScore,
|
||||
this.restSecondsOverride,
|
||||
}) : exerciseNameSnapshot = _nonBlank(
|
||||
exerciseNameSnapshot,
|
||||
'Exercise snapshot name',
|
||||
) {
|
||||
_requireNonNegative(position, 'Position');
|
||||
_requirePositive(setsCount, 'Sets count');
|
||||
_requireAtLeastOneMeasure(
|
||||
hasTime: timeEnabled,
|
||||
hasReps: repsEnabled,
|
||||
hasScore: scoreEnabled,
|
||||
);
|
||||
if (timeEnabled && !availableTimeSnapshot) {
|
||||
throw const DomainException('Time is not available on this exercise.');
|
||||
}
|
||||
if (repsEnabled && !availableRepsSnapshot) {
|
||||
throw const DomainException('Reps are not available on this exercise.');
|
||||
}
|
||||
if (scoreEnabled && !availableScoreSnapshot) {
|
||||
throw const DomainException('Score is not available on this exercise.');
|
||||
}
|
||||
_requireNullablePositive(targetTimeSeconds, 'Target time seconds');
|
||||
_requireNullablePositive(targetReps, 'Target reps');
|
||||
_requireNullableNonNegativeDouble(targetScore, 'Target score');
|
||||
_requireNullableNonNegative(restSecondsOverride, 'Rest seconds override');
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String programId;
|
||||
final String? sourceExerciseId;
|
||||
final int position;
|
||||
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 bool timeEnabled;
|
||||
final bool repsEnabled;
|
||||
final bool scoreEnabled;
|
||||
final int? targetTimeSeconds;
|
||||
final int? targetReps;
|
||||
final double? targetScore;
|
||||
final int? restSecondsOverride;
|
||||
|
||||
static ProgramExercise snapshotFromExercise({
|
||||
required EntityMetadata metadata,
|
||||
required String programId,
|
||||
required Exercise exercise,
|
||||
required int position,
|
||||
required int setsCount,
|
||||
required Set<WorkoutMeasure> enabledMeasures,
|
||||
int? targetTimeSeconds,
|
||||
int? targetReps,
|
||||
double? targetScore,
|
||||
int? restSecondsOverride,
|
||||
}) {
|
||||
final available = exercise.availableMeasures;
|
||||
if (!available.containsAll(enabledMeasures)) {
|
||||
throw const DomainException(
|
||||
'Enabled measures must be a subset of exercise measures.',
|
||||
);
|
||||
}
|
||||
return ProgramExercise(
|
||||
metadata: metadata,
|
||||
programId: programId,
|
||||
sourceExerciseId: exercise.metadata.id,
|
||||
position: position,
|
||||
exerciseNameSnapshot: exercise.name,
|
||||
exerciseDescriptionSnapshot: exercise.description,
|
||||
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
|
||||
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
|
||||
exerciseArchivedSnapshot: exercise.archivedAt != null,
|
||||
availableTimeSnapshot: exercise.hasTimeMeasure,
|
||||
availableRepsSnapshot: exercise.hasRepsMeasure,
|
||||
availableScoreSnapshot: exercise.hasScoreMeasure,
|
||||
scoreLabelSnapshot: exercise.scoreLabel,
|
||||
scoreUnitSnapshot: exercise.scoreUnit,
|
||||
setsCount: setsCount,
|
||||
timeEnabled: enabledMeasures.contains(WorkoutMeasure.time),
|
||||
repsEnabled: enabledMeasures.contains(WorkoutMeasure.reps),
|
||||
scoreEnabled: enabledMeasures.contains(WorkoutMeasure.score),
|
||||
targetTimeSeconds: targetTimeSeconds,
|
||||
targetReps: targetReps,
|
||||
targetScore: targetScore,
|
||||
restSecondsOverride: restSecondsOverride,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toSnapshotJson() => {
|
||||
'id': metadata.id,
|
||||
'sourceExerciseId': sourceExerciseId,
|
||||
'position': position,
|
||||
'exerciseNameSnapshot': exerciseNameSnapshot,
|
||||
'exerciseDescriptionSnapshot': exerciseDescriptionSnapshot,
|
||||
'exerciseImageMediaIdSnapshot': exerciseImageMediaIdSnapshot,
|
||||
'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot,
|
||||
'exerciseArchivedSnapshot': exerciseArchivedSnapshot,
|
||||
'availableTimeSnapshot': availableTimeSnapshot,
|
||||
'availableRepsSnapshot': availableRepsSnapshot,
|
||||
'availableScoreSnapshot': availableScoreSnapshot,
|
||||
'scoreLabelSnapshot': scoreLabelSnapshot,
|
||||
'scoreUnitSnapshot': scoreUnitSnapshot,
|
||||
'setsCount': setsCount,
|
||||
'timeEnabled': timeEnabled,
|
||||
'repsEnabled': repsEnabled,
|
||||
'scoreEnabled': scoreEnabled,
|
||||
'targetTimeSeconds': targetTimeSeconds,
|
||||
'targetReps': targetReps,
|
||||
'targetScore': targetScore,
|
||||
'restSecondsOverride': restSecondsOverride,
|
||||
};
|
||||
}
|
||||
|
||||
final class WorkoutTemplate {
|
||||
WorkoutTemplate({
|
||||
required this.metadata,
|
||||
required String name,
|
||||
this.lastStartedAt,
|
||||
this.programs = const [],
|
||||
this.overrides = const [],
|
||||
}) : name = _nonBlank(name, 'Workout template name');
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String name;
|
||||
final DateTime? lastStartedAt;
|
||||
final List<WorkoutTemplateProgram> programs;
|
||||
final List<WorkoutTemplateExerciseOverride> overrides;
|
||||
}
|
||||
|
||||
final class WorkoutTemplateProgram {
|
||||
WorkoutTemplateProgram({
|
||||
required this.metadata,
|
||||
required this.workoutTemplateId,
|
||||
this.sourceProgramId,
|
||||
required this.position,
|
||||
required String programNameSnapshot,
|
||||
required this.defaultRestSecondsSnapshot,
|
||||
required this.programSnapshotJson,
|
||||
}) : programNameSnapshot = _nonBlank(
|
||||
programNameSnapshot,
|
||||
'Program snapshot name',
|
||||
) {
|
||||
_requireNonNegative(position, 'Position');
|
||||
_requireNonNegative(defaultRestSecondsSnapshot, 'Default rest seconds');
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String workoutTemplateId;
|
||||
final String? sourceProgramId;
|
||||
final int position;
|
||||
final String programNameSnapshot;
|
||||
final int defaultRestSecondsSnapshot;
|
||||
final String programSnapshotJson;
|
||||
|
||||
static WorkoutTemplateProgram snapshotFromProgram({
|
||||
required EntityMetadata metadata,
|
||||
required String workoutTemplateId,
|
||||
required Program program,
|
||||
required int position,
|
||||
}) {
|
||||
return WorkoutTemplateProgram(
|
||||
metadata: metadata,
|
||||
workoutTemplateId: workoutTemplateId,
|
||||
sourceProgramId: program.metadata.id,
|
||||
position: position,
|
||||
programNameSnapshot: program.name,
|
||||
defaultRestSecondsSnapshot: program.defaultRestSeconds,
|
||||
programSnapshotJson: jsonEncode({
|
||||
'programId': program.metadata.id,
|
||||
'name': program.name,
|
||||
'defaultRestSeconds': program.defaultRestSeconds,
|
||||
'exercises': program.exercises
|
||||
.map((exercise) => exercise.toSnapshotJson())
|
||||
.toList(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkoutTemplateExerciseOverride {
|
||||
WorkoutTemplateExerciseOverride({
|
||||
required this.metadata,
|
||||
required this.workoutTemplateProgramId,
|
||||
required String snapshotProgramExerciseId,
|
||||
this.setsCountOverride,
|
||||
this.targetTimeSecondsOverride,
|
||||
this.targetRepsOverride,
|
||||
this.targetScoreOverride,
|
||||
}) : snapshotProgramExerciseId = _nonBlank(
|
||||
snapshotProgramExerciseId,
|
||||
'Snapshot program exercise id',
|
||||
) {
|
||||
_requireNullablePositive(setsCountOverride, 'Sets count override');
|
||||
_requireNullablePositive(
|
||||
targetTimeSecondsOverride,
|
||||
'Target time seconds override',
|
||||
);
|
||||
_requireNullablePositive(targetRepsOverride, 'Target reps override');
|
||||
_requireNullableNonNegativeDouble(
|
||||
targetScoreOverride,
|
||||
'Target score override',
|
||||
);
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String workoutTemplateProgramId;
|
||||
final String snapshotProgramExerciseId;
|
||||
final int? setsCountOverride;
|
||||
final int? targetTimeSecondsOverride;
|
||||
final int? targetRepsOverride;
|
||||
final double? targetScoreOverride;
|
||||
}
|
||||
|
||||
final class ActiveWorkoutSession {
|
||||
ActiveWorkoutSession({
|
||||
required this.metadata,
|
||||
this.sourceWorkoutTemplateId,
|
||||
required this.status,
|
||||
required this.startedAt,
|
||||
this.pausedAt,
|
||||
this.endedAt,
|
||||
required this.lastPersistedAt,
|
||||
required this.elapsedActiveMs,
|
||||
required this.currentProgramIndex,
|
||||
required this.currentExerciseIndex,
|
||||
required this.currentSetIndex,
|
||||
required this.resolvedTemplateSnapshotJson,
|
||||
}) {
|
||||
_requireNonNegative(elapsedActiveMs, 'Elapsed active milliseconds');
|
||||
_requireNonNegative(currentProgramIndex, 'Current program index');
|
||||
_requireNonNegative(currentExerciseIndex, 'Current exercise index');
|
||||
_requireNonNegative(currentSetIndex, 'Current set index');
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String? sourceWorkoutTemplateId;
|
||||
final ActiveWorkoutStatus status;
|
||||
final DateTime startedAt;
|
||||
final DateTime? pausedAt;
|
||||
final DateTime? endedAt;
|
||||
final DateTime lastPersistedAt;
|
||||
final int elapsedActiveMs;
|
||||
final int currentProgramIndex;
|
||||
final int currentExerciseIndex;
|
||||
final int currentSetIndex;
|
||||
final String resolvedTemplateSnapshotJson;
|
||||
|
||||
int elapsedActiveMillisecondsAt(DateTime now) {
|
||||
if (status != ActiveWorkoutStatus.running) {
|
||||
return elapsedActiveMs;
|
||||
}
|
||||
return elapsedActiveMs + now.difference(lastPersistedAt).inMilliseconds;
|
||||
}
|
||||
|
||||
ActiveWorkoutSession pause(DateTime now) {
|
||||
return copyWith(
|
||||
status: ActiveWorkoutStatus.paused,
|
||||
pausedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: elapsedActiveMillisecondsAt(now),
|
||||
metadata: metadata.touch(now),
|
||||
);
|
||||
}
|
||||
|
||||
ActiveWorkoutSession resume(DateTime now) {
|
||||
return ActiveWorkoutSession(
|
||||
metadata: metadata.touch(now),
|
||||
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: startedAt,
|
||||
pausedAt: null,
|
||||
endedAt: endedAt,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: elapsedActiveMs,
|
||||
currentProgramIndex: currentProgramIndex,
|
||||
currentExerciseIndex: currentExerciseIndex,
|
||||
currentSetIndex: currentSetIndex,
|
||||
resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
|
||||
);
|
||||
}
|
||||
|
||||
ActiveWorkoutSession copyWith({
|
||||
EntityMetadata? metadata,
|
||||
ActiveWorkoutStatus? status,
|
||||
DateTime? pausedAt,
|
||||
DateTime? endedAt,
|
||||
DateTime? lastPersistedAt,
|
||||
int? elapsedActiveMs,
|
||||
int? currentProgramIndex,
|
||||
int? currentExerciseIndex,
|
||||
int? currentSetIndex,
|
||||
}) {
|
||||
return ActiveWorkoutSession(
|
||||
metadata: metadata ?? this.metadata,
|
||||
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
|
||||
status: status ?? this.status,
|
||||
startedAt: startedAt,
|
||||
pausedAt: pausedAt,
|
||||
endedAt: endedAt ?? this.endedAt,
|
||||
lastPersistedAt: lastPersistedAt ?? this.lastPersistedAt,
|
||||
elapsedActiveMs: elapsedActiveMs ?? this.elapsedActiveMs,
|
||||
currentProgramIndex: currentProgramIndex ?? this.currentProgramIndex,
|
||||
currentExerciseIndex: currentExerciseIndex ?? this.currentExerciseIndex,
|
||||
currentSetIndex: currentSetIndex ?? this.currentSetIndex,
|
||||
resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class ActiveSetResult {
|
||||
const ActiveSetResult({
|
||||
required this.metadata,
|
||||
required this.activeWorkoutSessionId,
|
||||
required this.programSnapshotId,
|
||||
required this.exerciseSnapshotId,
|
||||
required this.programIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.setIndex,
|
||||
this.startedAt,
|
||||
this.completedAt,
|
||||
this.actualTimeMs,
|
||||
this.actualReps,
|
||||
this.actualScore,
|
||||
this.scoreLabelSnapshot,
|
||||
this.scoreUnitSnapshot,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String activeWorkoutSessionId;
|
||||
final String programSnapshotId;
|
||||
final String exerciseSnapshotId;
|
||||
final int programIndex;
|
||||
final int exerciseIndex;
|
||||
final int setIndex;
|
||||
final DateTime? startedAt;
|
||||
final DateTime? completedAt;
|
||||
final int? actualTimeMs;
|
||||
final int? actualReps;
|
||||
final double? actualScore;
|
||||
final String? scoreLabelSnapshot;
|
||||
final String? scoreUnitSnapshot;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
final class ActiveRestState {
|
||||
const ActiveRestState({
|
||||
required this.metadata,
|
||||
required this.activeWorkoutSessionId,
|
||||
required this.afterProgramIndex,
|
||||
required this.afterExerciseIndex,
|
||||
required this.afterSetIndex,
|
||||
required this.plannedRestSeconds,
|
||||
required this.adjustedRestSeconds,
|
||||
required this.startedAt,
|
||||
this.endedAt,
|
||||
this.skippedAt,
|
||||
});
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String activeWorkoutSessionId;
|
||||
final int afterProgramIndex;
|
||||
final int afterExerciseIndex;
|
||||
final int afterSetIndex;
|
||||
final int plannedRestSeconds;
|
||||
final int adjustedRestSeconds;
|
||||
final DateTime startedAt;
|
||||
final DateTime? endedAt;
|
||||
final DateTime? skippedAt;
|
||||
}
|
||||
|
||||
final class WorkoutHistory {
|
||||
const WorkoutHistory({
|
||||
required this.metadata,
|
||||
this.sourceWorkoutTemplateId,
|
||||
this.sourceActiveWorkoutSessionId,
|
||||
required this.nameSnapshot,
|
||||
required this.startedAt,
|
||||
required this.endedAt,
|
||||
required this.totalActiveMs,
|
||||
required this.completed,
|
||||
required this.historySnapshotJson,
|
||||
this.results = const [],
|
||||
});
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String? sourceWorkoutTemplateId;
|
||||
final String? sourceActiveWorkoutSessionId;
|
||||
final String nameSnapshot;
|
||||
final DateTime startedAt;
|
||||
final DateTime endedAt;
|
||||
final int totalActiveMs;
|
||||
final bool completed;
|
||||
final String historySnapshotJson;
|
||||
final List<WorkoutHistorySetResult> results;
|
||||
}
|
||||
|
||||
final class WorkoutHistorySetResult {
|
||||
const WorkoutHistorySetResult({
|
||||
required this.metadata,
|
||||
required this.workoutHistoryId,
|
||||
required this.programSnapshotId,
|
||||
required this.exerciseSnapshotId,
|
||||
required this.programIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.setIndex,
|
||||
required this.programNameSnapshot,
|
||||
required this.exerciseNameSnapshot,
|
||||
required this.timeEnabledSnapshot,
|
||||
required this.repsEnabledSnapshot,
|
||||
required this.scoreEnabledSnapshot,
|
||||
this.targetTimeSecondsSnapshot,
|
||||
this.targetRepsSnapshot,
|
||||
this.targetScoreSnapshot,
|
||||
this.actualTimeMs,
|
||||
this.actualReps,
|
||||
this.actualScore,
|
||||
this.scoreLabelSnapshot,
|
||||
this.scoreUnitSnapshot,
|
||||
this.startedAt,
|
||||
this.completedAt,
|
||||
});
|
||||
|
||||
final EntityMetadata metadata;
|
||||
final String workoutHistoryId;
|
||||
final String programSnapshotId;
|
||||
final String exerciseSnapshotId;
|
||||
final int programIndex;
|
||||
final int exerciseIndex;
|
||||
final int setIndex;
|
||||
final String programNameSnapshot;
|
||||
final String exerciseNameSnapshot;
|
||||
final bool timeEnabledSnapshot;
|
||||
final bool repsEnabledSnapshot;
|
||||
final bool scoreEnabledSnapshot;
|
||||
final int? targetTimeSecondsSnapshot;
|
||||
final int? targetRepsSnapshot;
|
||||
final double? targetScoreSnapshot;
|
||||
final int? actualTimeMs;
|
||||
final int? actualReps;
|
||||
final double? actualScore;
|
||||
final String? scoreLabelSnapshot;
|
||||
final String? scoreUnitSnapshot;
|
||||
final DateTime? startedAt;
|
||||
final DateTime? completedAt;
|
||||
}
|
||||
|
||||
String _nonBlank(String? value, String label) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) {
|
||||
throw DomainException('$label must not be blank.');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
void _requireAtLeastOneMeasure({
|
||||
required bool hasTime,
|
||||
required bool hasReps,
|
||||
required bool hasScore,
|
||||
}) {
|
||||
if (!hasTime && !hasReps && !hasScore) {
|
||||
throw const DomainException('At least one measure must be active.');
|
||||
}
|
||||
}
|
||||
|
||||
void _requirePositive(int value, String label) {
|
||||
if (value <= 0) {
|
||||
throw DomainException('$label must be positive.');
|
||||
}
|
||||
}
|
||||
|
||||
void _requireNonNegative(int value, String label) {
|
||||
if (value < 0) {
|
||||
throw DomainException('$label must not be negative.');
|
||||
}
|
||||
}
|
||||
|
||||
void _requireNullablePositive(int? value, String label) {
|
||||
if (value != null) {
|
||||
_requirePositive(value, label);
|
||||
}
|
||||
}
|
||||
|
||||
void _requireNullableNonNegative(int? value, String label) {
|
||||
if (value != null) {
|
||||
_requireNonNegative(value, label);
|
||||
}
|
||||
}
|
||||
|
||||
void _requireNullableNonNegativeDouble(double? value, String label) {
|
||||
if (value != null && value < 0) {
|
||||
throw DomainException('$label must not be negative.');
|
||||
}
|
||||
}
|
||||
751
lib/infrastructure/local/drift_repositories.dart
Normal file
751
lib/infrastructure/local/drift_repositories.dart
Normal file
@ -0,0 +1,751 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../application/application.dart';
|
||||
import '../../domain/domain.dart' as domain;
|
||||
import 'app_database.dart' as db;
|
||||
|
||||
final class DriftExerciseRepository implements ExerciseRepository {
|
||||
const DriftExerciseRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<domain.Exercise?> findById(String id) async {
|
||||
final row = await (database.select(
|
||||
database.exercises,
|
||||
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
||||
return row == null ? null : _exerciseFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.Exercise>> listActive() async {
|
||||
final rows =
|
||||
await (database.select(database.exercises)
|
||||
..where((table) => table.deletedAt.isNull())
|
||||
..orderBy([(table) => OrderingTerm.asc(table.name)]))
|
||||
.get();
|
||||
return rows.map(_exerciseFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(domain.Exercise exercise) async {
|
||||
await database
|
||||
.into(database.exercises)
|
||||
.insertOnConflictUpdate(_exerciseCompanion(exercise));
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftProgramRepository implements ProgramRepository {
|
||||
const DriftProgramRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<domain.Program?> findById(String id) async {
|
||||
final row = await (database.select(
|
||||
database.programs,
|
||||
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
final exercises =
|
||||
await (database.select(database.programExercises)
|
||||
..where((table) => table.programId.equals(id))
|
||||
..orderBy([(table) => OrderingTerm.asc(table.position)]))
|
||||
.get();
|
||||
return _programFromRow(
|
||||
row,
|
||||
exercises.map(_programExerciseFromRow).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(domain.Program program) async {
|
||||
await database
|
||||
.into(database.programs)
|
||||
.insertOnConflictUpdate(_programCompanion(program));
|
||||
for (final exercise in program.exercises) {
|
||||
await saveExercise(exercise);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExercise(domain.ProgramExercise exercise) async {
|
||||
await database
|
||||
.into(database.programExercises)
|
||||
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftWorkoutTemplateRepository
|
||||
implements WorkoutTemplateRepository {
|
||||
const DriftWorkoutTemplateRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<domain.WorkoutTemplate?> findById(String id) async {
|
||||
final row = await (database.select(
|
||||
database.workoutTemplates,
|
||||
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
final programs =
|
||||
await (database.select(database.workoutTemplatePrograms)
|
||||
..where((table) => table.workoutTemplateId.equals(id))
|
||||
..orderBy([(table) => OrderingTerm.asc(table.position)]))
|
||||
.get();
|
||||
final programIds = programs.map((program) => program.id).toSet();
|
||||
final overrides = await (database.select(
|
||||
database.workoutTemplateExerciseOverrides,
|
||||
)..where((table) => table.workoutTemplateProgramId.isIn(programIds))).get();
|
||||
return _workoutTemplateFromRow(
|
||||
row,
|
||||
programs.map(_workoutTemplateProgramFromRow).toList(),
|
||||
overrides.map(_workoutTemplateExerciseOverrideFromRow).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(domain.WorkoutTemplate template) async {
|
||||
await database
|
||||
.into(database.workoutTemplates)
|
||||
.insertOnConflictUpdate(_workoutTemplateCompanion(template));
|
||||
for (final program in template.programs) {
|
||||
await saveProgram(program);
|
||||
}
|
||||
for (final override in template.overrides) {
|
||||
await saveOverride(override);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveProgram(domain.WorkoutTemplateProgram program) async {
|
||||
await database
|
||||
.into(database.workoutTemplatePrograms)
|
||||
.insertOnConflictUpdate(_workoutTemplateProgramCompanion(program));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(
|
||||
domain.WorkoutTemplateExerciseOverride override,
|
||||
) async {
|
||||
await database
|
||||
.into(database.workoutTemplateExerciseOverrides)
|
||||
.insertOnConflictUpdate(
|
||||
_workoutTemplateExerciseOverrideCompanion(override),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
const DriftActiveSessionRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<domain.ActiveWorkoutSession?> findById(String id) async {
|
||||
final row = await (database.select(
|
||||
database.activeWorkoutSessions,
|
||||
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
||||
return row == null ? null : _activeWorkoutSessionFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(domain.ActiveWorkoutSession session) async {
|
||||
await database
|
||||
.into(database.activeWorkoutSessions)
|
||||
.insertOnConflictUpdate(_activeWorkoutSessionCompanion(session));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(domain.ActiveSetResult result) async {
|
||||
await database
|
||||
.into(database.activeSetResults)
|
||||
.insertOnConflictUpdate(_activeSetResultCompanion(result));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveRestState(domain.ActiveRestState restState) async {
|
||||
await database
|
||||
.into(database.activeRestStates)
|
||||
.insertOnConflictUpdate(_activeRestStateCompanion(restState));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
final rows =
|
||||
await (database.select(database.activeSetResults)
|
||||
..where((table) => table.activeWorkoutSessionId.equals(sessionId))
|
||||
..orderBy([
|
||||
(table) => OrderingTerm.asc(table.programIndex),
|
||||
(table) => OrderingTerm.asc(table.exerciseIndex),
|
||||
(table) => OrderingTerm.asc(table.setIndex),
|
||||
]))
|
||||
.get();
|
||||
return rows.map(_activeSetResultFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
|
||||
final rows =
|
||||
await (database.select(database.activeRestStates)
|
||||
..where((table) => table.activeWorkoutSessionId.equals(sessionId))
|
||||
..orderBy([(table) => OrderingTerm.asc(table.startedAt)]))
|
||||
.get();
|
||||
return rows.map(_activeRestStateFromRow).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
const DriftWorkoutHistoryRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<void> save(domain.WorkoutHistory history) async {
|
||||
await database
|
||||
.into(database.workoutHistories)
|
||||
.insertOnConflictUpdate(_workoutHistoryCompanion(history));
|
||||
for (final result in history.results) {
|
||||
await saveSetResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
|
||||
await database
|
||||
.into(database.workoutHistorySetResults)
|
||||
.insertOnConflictUpdate(_workoutHistorySetResultCompanion(result));
|
||||
}
|
||||
}
|
||||
|
||||
domain.EntityMetadata _metadataFromRow(dynamic row) {
|
||||
return domain.EntityMetadata(
|
||||
id: row.id as String,
|
||||
createdAt: row.createdAt as DateTime,
|
||||
updatedAt: row.updatedAt as DateTime,
|
||||
deletedAt: row.deletedAt as DateTime?,
|
||||
schemaVersion: row.schemaVersion as int,
|
||||
syncState: _syncStateFromDb(row.syncState as String),
|
||||
localRevision: row.localRevision as int,
|
||||
originDeviceId: row.originDeviceId as String,
|
||||
futureOwnerProfileId: row.futureOwnerProfileId as String?,
|
||||
lastSyncedAt: row.lastSyncedAt as DateTime?,
|
||||
remoteRevision: row.remoteRevision as String?,
|
||||
);
|
||||
}
|
||||
|
||||
List<Value<Object?>> _metadataValues(domain.EntityMetadata metadata) => [
|
||||
Value(metadata.id),
|
||||
Value(metadata.createdAt),
|
||||
Value(metadata.updatedAt),
|
||||
Value(metadata.deletedAt),
|
||||
Value(metadata.schemaVersion),
|
||||
Value(_syncStateToDb(metadata.syncState)),
|
||||
Value(metadata.localRevision),
|
||||
Value(metadata.originDeviceId),
|
||||
Value(metadata.futureOwnerProfileId),
|
||||
Value(metadata.lastSyncedAt),
|
||||
Value(metadata.remoteRevision),
|
||||
];
|
||||
|
||||
db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
|
||||
final values = _metadataValues(exercise.metadata);
|
||||
return db.ExercisesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
name: Value(exercise.name),
|
||||
description: Value(exercise.description),
|
||||
imageMediaId: Value(exercise.imageMediaId),
|
||||
videoMediaId: Value(exercise.videoMediaId),
|
||||
hasTimeMeasure: Value(exercise.hasTimeMeasure),
|
||||
hasRepsMeasure: Value(exercise.hasRepsMeasure),
|
||||
hasScoreMeasure: Value(exercise.hasScoreMeasure),
|
||||
scoreLabel: Value(exercise.scoreLabel),
|
||||
scoreUnit: Value(exercise.scoreUnit),
|
||||
archivedAt: Value(exercise.archivedAt),
|
||||
);
|
||||
}
|
||||
|
||||
domain.Exercise _exerciseFromRow(db.Exercise row) {
|
||||
return domain.Exercise(
|
||||
metadata: _metadataFromRow(row),
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
imageMediaId: row.imageMediaId,
|
||||
videoMediaId: row.videoMediaId,
|
||||
hasTimeMeasure: row.hasTimeMeasure,
|
||||
hasRepsMeasure: row.hasRepsMeasure,
|
||||
hasScoreMeasure: row.hasScoreMeasure,
|
||||
scoreLabel: row.scoreLabel,
|
||||
scoreUnit: row.scoreUnit,
|
||||
archivedAt: row.archivedAt,
|
||||
);
|
||||
}
|
||||
|
||||
db.ProgramsCompanion _programCompanion(domain.Program program) {
|
||||
final values = _metadataValues(program.metadata);
|
||||
return db.ProgramsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
name: Value(program.name),
|
||||
defaultRestSeconds: Value(program.defaultRestSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
domain.Program _programFromRow(
|
||||
db.Program row,
|
||||
List<domain.ProgramExercise> exercises,
|
||||
) {
|
||||
return domain.Program(
|
||||
metadata: _metadataFromRow(row),
|
||||
name: row.name,
|
||||
defaultRestSeconds: row.defaultRestSeconds,
|
||||
exercises: exercises,
|
||||
);
|
||||
}
|
||||
|
||||
db.ProgramExercisesCompanion _programExerciseCompanion(
|
||||
domain.ProgramExercise exercise,
|
||||
) {
|
||||
final values = _metadataValues(exercise.metadata);
|
||||
return db.ProgramExercisesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
programId: Value(exercise.programId),
|
||||
sourceExerciseId: Value(exercise.sourceExerciseId),
|
||||
position: Value(exercise.position),
|
||||
exerciseNameSnapshot: Value(exercise.exerciseNameSnapshot),
|
||||
exerciseDescriptionSnapshot: Value(exercise.exerciseDescriptionSnapshot),
|
||||
exerciseImageMediaIdSnapshot: Value(exercise.exerciseImageMediaIdSnapshot),
|
||||
exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot),
|
||||
exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot),
|
||||
availableTimeSnapshot: Value(exercise.availableTimeSnapshot),
|
||||
availableRepsSnapshot: Value(exercise.availableRepsSnapshot),
|
||||
availableScoreSnapshot: Value(exercise.availableScoreSnapshot),
|
||||
scoreLabelSnapshot: Value(exercise.scoreLabelSnapshot),
|
||||
scoreUnitSnapshot: Value(exercise.scoreUnitSnapshot),
|
||||
setsCount: Value(exercise.setsCount),
|
||||
timeEnabled: Value(exercise.timeEnabled),
|
||||
repsEnabled: Value(exercise.repsEnabled),
|
||||
scoreEnabled: Value(exercise.scoreEnabled),
|
||||
targetTimeSeconds: Value(exercise.targetTimeSeconds),
|
||||
targetReps: Value(exercise.targetReps),
|
||||
targetScore: Value(exercise.targetScore),
|
||||
restSecondsOverride: Value(exercise.restSecondsOverride),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
|
||||
return domain.ProgramExercise(
|
||||
metadata: _metadataFromRow(row),
|
||||
programId: row.programId,
|
||||
sourceExerciseId: row.sourceExerciseId,
|
||||
position: row.position,
|
||||
exerciseNameSnapshot: row.exerciseNameSnapshot,
|
||||
exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot,
|
||||
exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot,
|
||||
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
|
||||
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
|
||||
availableTimeSnapshot: row.availableTimeSnapshot,
|
||||
availableRepsSnapshot: row.availableRepsSnapshot,
|
||||
availableScoreSnapshot: row.availableScoreSnapshot,
|
||||
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
||||
setsCount: row.setsCount,
|
||||
timeEnabled: row.timeEnabled,
|
||||
repsEnabled: row.repsEnabled,
|
||||
scoreEnabled: row.scoreEnabled,
|
||||
targetTimeSeconds: row.targetTimeSeconds,
|
||||
targetReps: row.targetReps,
|
||||
targetScore: row.targetScore,
|
||||
restSecondsOverride: row.restSecondsOverride,
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutTemplatesCompanion _workoutTemplateCompanion(
|
||||
domain.WorkoutTemplate template,
|
||||
) {
|
||||
final values = _metadataValues(template.metadata);
|
||||
return db.WorkoutTemplatesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
name: Value(template.name),
|
||||
lastStartedAt: Value(template.lastStartedAt),
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutTemplate _workoutTemplateFromRow(
|
||||
db.WorkoutTemplate row,
|
||||
List<domain.WorkoutTemplateProgram> programs,
|
||||
List<domain.WorkoutTemplateExerciseOverride> overrides,
|
||||
) {
|
||||
return domain.WorkoutTemplate(
|
||||
metadata: _metadataFromRow(row),
|
||||
name: row.name,
|
||||
lastStartedAt: row.lastStartedAt,
|
||||
programs: programs,
|
||||
overrides: overrides,
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutTemplateProgramsCompanion _workoutTemplateProgramCompanion(
|
||||
domain.WorkoutTemplateProgram program,
|
||||
) {
|
||||
final values = _metadataValues(program.metadata);
|
||||
return db.WorkoutTemplateProgramsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
workoutTemplateId: Value(program.workoutTemplateId),
|
||||
sourceProgramId: Value(program.sourceProgramId),
|
||||
position: Value(program.position),
|
||||
programNameSnapshot: Value(program.programNameSnapshot),
|
||||
defaultRestSecondsSnapshot: Value(program.defaultRestSecondsSnapshot),
|
||||
programSnapshotJson: Value(program.programSnapshotJson),
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutTemplateProgram _workoutTemplateProgramFromRow(
|
||||
db.WorkoutTemplateProgram row,
|
||||
) {
|
||||
return domain.WorkoutTemplateProgram(
|
||||
metadata: _metadataFromRow(row),
|
||||
workoutTemplateId: row.workoutTemplateId,
|
||||
sourceProgramId: row.sourceProgramId,
|
||||
position: row.position,
|
||||
programNameSnapshot: row.programNameSnapshot,
|
||||
defaultRestSecondsSnapshot: row.defaultRestSecondsSnapshot,
|
||||
programSnapshotJson: row.programSnapshotJson,
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutTemplateExerciseOverridesCompanion
|
||||
_workoutTemplateExerciseOverrideCompanion(
|
||||
domain.WorkoutTemplateExerciseOverride override,
|
||||
) {
|
||||
final values = _metadataValues(override.metadata);
|
||||
return db.WorkoutTemplateExerciseOverridesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
workoutTemplateProgramId: Value(override.workoutTemplateProgramId),
|
||||
snapshotProgramExerciseId: Value(override.snapshotProgramExerciseId),
|
||||
setsCountOverride: Value(override.setsCountOverride),
|
||||
targetTimeSecondsOverride: Value(override.targetTimeSecondsOverride),
|
||||
targetRepsOverride: Value(override.targetRepsOverride),
|
||||
targetScoreOverride: Value(override.targetScoreOverride),
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutTemplateExerciseOverride _workoutTemplateExerciseOverrideFromRow(
|
||||
db.WorkoutTemplateExerciseOverride row,
|
||||
) {
|
||||
return domain.WorkoutTemplateExerciseOverride(
|
||||
metadata: _metadataFromRow(row),
|
||||
workoutTemplateProgramId: row.workoutTemplateProgramId,
|
||||
snapshotProgramExerciseId: row.snapshotProgramExerciseId,
|
||||
setsCountOverride: row.setsCountOverride,
|
||||
targetTimeSecondsOverride: row.targetTimeSecondsOverride,
|
||||
targetRepsOverride: row.targetRepsOverride,
|
||||
targetScoreOverride: row.targetScoreOverride,
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveWorkoutSessionsCompanion _activeWorkoutSessionCompanion(
|
||||
domain.ActiveWorkoutSession session,
|
||||
) {
|
||||
final values = _metadataValues(session.metadata);
|
||||
return db.ActiveWorkoutSessionsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
sourceWorkoutTemplateId: Value(session.sourceWorkoutTemplateId),
|
||||
status: Value(_activeStatusToDb(session.status)),
|
||||
startedAt: Value(session.startedAt),
|
||||
pausedAt: Value(session.pausedAt),
|
||||
endedAt: Value(session.endedAt),
|
||||
lastPersistedAt: Value(session.lastPersistedAt),
|
||||
elapsedActiveMs: Value(session.elapsedActiveMs),
|
||||
currentProgramIndex: Value(session.currentProgramIndex),
|
||||
currentExerciseIndex: Value(session.currentExerciseIndex),
|
||||
currentSetIndex: Value(session.currentSetIndex),
|
||||
resolvedTemplateSnapshotJson: Value(session.resolvedTemplateSnapshotJson),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveWorkoutSession _activeWorkoutSessionFromRow(
|
||||
db.ActiveWorkoutSession row,
|
||||
) {
|
||||
return domain.ActiveWorkoutSession(
|
||||
metadata: _metadataFromRow(row),
|
||||
sourceWorkoutTemplateId: row.sourceWorkoutTemplateId,
|
||||
status: _activeStatusFromDb(row.status),
|
||||
startedAt: row.startedAt,
|
||||
pausedAt: row.pausedAt,
|
||||
endedAt: row.endedAt,
|
||||
lastPersistedAt: row.lastPersistedAt,
|
||||
elapsedActiveMs: row.elapsedActiveMs,
|
||||
currentProgramIndex: row.currentProgramIndex,
|
||||
currentExerciseIndex: row.currentExerciseIndex,
|
||||
currentSetIndex: row.currentSetIndex,
|
||||
resolvedTemplateSnapshotJson: row.resolvedTemplateSnapshotJson,
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveSetResultsCompanion _activeSetResultCompanion(
|
||||
domain.ActiveSetResult result,
|
||||
) {
|
||||
final values = _metadataValues(result.metadata);
|
||||
return db.ActiveSetResultsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
activeWorkoutSessionId: Value(result.activeWorkoutSessionId),
|
||||
programSnapshotId: Value(result.programSnapshotId),
|
||||
exerciseSnapshotId: Value(result.exerciseSnapshotId),
|
||||
programIndex: Value(result.programIndex),
|
||||
exerciseIndex: Value(result.exerciseIndex),
|
||||
setIndex: Value(result.setIndex),
|
||||
startedAt: Value(result.startedAt),
|
||||
completedAt: Value(result.completedAt),
|
||||
actualTimeMs: Value(result.actualTimeMs),
|
||||
actualReps: Value(result.actualReps),
|
||||
actualScore: Value(result.actualScore),
|
||||
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
|
||||
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
|
||||
note: Value(result.note),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) {
|
||||
return domain.ActiveSetResult(
|
||||
metadata: _metadataFromRow(row),
|
||||
activeWorkoutSessionId: row.activeWorkoutSessionId,
|
||||
programSnapshotId: row.programSnapshotId,
|
||||
exerciseSnapshotId: row.exerciseSnapshotId,
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
startedAt: row.startedAt,
|
||||
completedAt: row.completedAt,
|
||||
actualTimeMs: row.actualTimeMs,
|
||||
actualReps: row.actualReps,
|
||||
actualScore: row.actualScore,
|
||||
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
||||
note: row.note,
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveRestStatesCompanion _activeRestStateCompanion(
|
||||
domain.ActiveRestState restState,
|
||||
) {
|
||||
final values = _metadataValues(restState.metadata);
|
||||
return db.ActiveRestStatesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
activeWorkoutSessionId: Value(restState.activeWorkoutSessionId),
|
||||
afterProgramIndex: Value(restState.afterProgramIndex),
|
||||
afterExerciseIndex: Value(restState.afterExerciseIndex),
|
||||
afterSetIndex: Value(restState.afterSetIndex),
|
||||
plannedRestSeconds: Value(restState.plannedRestSeconds),
|
||||
adjustedRestSeconds: Value(restState.adjustedRestSeconds),
|
||||
startedAt: Value(restState.startedAt),
|
||||
endedAt: Value(restState.endedAt),
|
||||
skippedAt: Value(restState.skippedAt),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveRestState _activeRestStateFromRow(db.ActiveRestState row) {
|
||||
return domain.ActiveRestState(
|
||||
metadata: _metadataFromRow(row),
|
||||
activeWorkoutSessionId: row.activeWorkoutSessionId,
|
||||
afterProgramIndex: row.afterProgramIndex,
|
||||
afterExerciseIndex: row.afterExerciseIndex,
|
||||
afterSetIndex: row.afterSetIndex,
|
||||
plannedRestSeconds: row.plannedRestSeconds,
|
||||
adjustedRestSeconds: row.adjustedRestSeconds,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
skippedAt: row.skippedAt,
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
|
||||
domain.WorkoutHistory history,
|
||||
) {
|
||||
final values = _metadataValues(history.metadata);
|
||||
return db.WorkoutHistoriesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
sourceWorkoutTemplateId: Value(history.sourceWorkoutTemplateId),
|
||||
sourceActiveWorkoutSessionId: Value(history.sourceActiveWorkoutSessionId),
|
||||
nameSnapshot: Value(history.nameSnapshot),
|
||||
startedAt: Value(history.startedAt),
|
||||
endedAt: Value(history.endedAt),
|
||||
totalActiveMs: Value(history.totalActiveMs),
|
||||
completed: Value(history.completed),
|
||||
historySnapshotJson: Value(history.historySnapshotJson),
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
|
||||
domain.WorkoutHistorySetResult result,
|
||||
) {
|
||||
final values = _metadataValues(result.metadata);
|
||||
return db.WorkoutHistorySetResultsCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
workoutHistoryId: Value(result.workoutHistoryId),
|
||||
programSnapshotId: Value(result.programSnapshotId),
|
||||
exerciseSnapshotId: Value(result.exerciseSnapshotId),
|
||||
programIndex: Value(result.programIndex),
|
||||
exerciseIndex: Value(result.exerciseIndex),
|
||||
setIndex: Value(result.setIndex),
|
||||
programNameSnapshot: Value(result.programNameSnapshot),
|
||||
exerciseNameSnapshot: Value(result.exerciseNameSnapshot),
|
||||
timeEnabledSnapshot: Value(result.timeEnabledSnapshot),
|
||||
repsEnabledSnapshot: Value(result.repsEnabledSnapshot),
|
||||
scoreEnabledSnapshot: Value(result.scoreEnabledSnapshot),
|
||||
targetTimeSecondsSnapshot: Value(result.targetTimeSecondsSnapshot),
|
||||
targetRepsSnapshot: Value(result.targetRepsSnapshot),
|
||||
targetScoreSnapshot: Value(result.targetScoreSnapshot),
|
||||
actualTimeMs: Value(result.actualTimeMs),
|
||||
actualReps: Value(result.actualReps),
|
||||
actualScore: Value(result.actualScore),
|
||||
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
|
||||
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
|
||||
startedAt: Value(result.startedAt),
|
||||
completedAt: Value(result.completedAt),
|
||||
);
|
||||
}
|
||||
|
||||
String _syncStateToDb(domain.SyncState state) => switch (state) {
|
||||
domain.SyncState.localOnly => 'localOnly',
|
||||
domain.SyncState.dirty => 'dirty',
|
||||
domain.SyncState.synced => 'synced',
|
||||
domain.SyncState.deleted => 'deleted',
|
||||
};
|
||||
|
||||
domain.SyncState _syncStateFromDb(String value) => switch (value) {
|
||||
'localOnly' => domain.SyncState.localOnly,
|
||||
'dirty' => domain.SyncState.dirty,
|
||||
'synced' => domain.SyncState.synced,
|
||||
'deleted' => domain.SyncState.deleted,
|
||||
_ => throw domain.DomainException('Unknown sync state: $value'),
|
||||
};
|
||||
|
||||
String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) {
|
||||
domain.ActiveWorkoutStatus.running => 'running',
|
||||
domain.ActiveWorkoutStatus.paused => 'paused',
|
||||
domain.ActiveWorkoutStatus.savedExit => 'savedExit',
|
||||
domain.ActiveWorkoutStatus.completed => 'completed',
|
||||
domain.ActiveWorkoutStatus.abandoned => 'abandoned',
|
||||
};
|
||||
|
||||
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
|
||||
'running' => domain.ActiveWorkoutStatus.running,
|
||||
'paused' => domain.ActiveWorkoutStatus.paused,
|
||||
'savedExit' => domain.ActiveWorkoutStatus.savedExit,
|
||||
'completed' => domain.ActiveWorkoutStatus.completed,
|
||||
'abandoned' => domain.ActiveWorkoutStatus.abandoned,
|
||||
_ => throw domain.DomainException('Unknown active workout status: $value'),
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export 'app_database.dart';
|
||||
export 'drift_repositories.dart';
|
||||
export 'local_id.dart';
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import 'dart:math';
|
||||
|
||||
import '../../application/ports.dart';
|
||||
|
||||
const _crockfordBase32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
|
||||
final class LocalIdGenerator {
|
||||
final class LocalIdGenerator implements IdGenerator {
|
||||
LocalIdGenerator({Random? random}) : _random = random ?? Random.secure();
|
||||
|
||||
final Random _random;
|
||||
|
||||
@override
|
||||
String newId() => newUlid();
|
||||
|
||||
String newUlid({DateTime? now}) {
|
||||
final timestamp = (now ?? DateTime.now().toUtc()).millisecondsSinceEpoch;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
117
test/application/use_cases_test.dart
Normal file
117
test/application/use_cases_test.dart
Normal file
@ -0,0 +1,117 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
|
||||
void main() {
|
||||
test('Exercise requires at least one active measure', () {
|
||||
expect(
|
||||
() => Exercise(
|
||||
metadata: _metadata('exercise-1'),
|
||||
name: 'Push up',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: false,
|
||||
hasScoreMeasure: false,
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Workout template use case rejects active measure overrides', () async {
|
||||
final useCase = WorkoutTemplateUseCases(
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
programRepository: _FakeProgramRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
useCase.overrideExerciseTargets(
|
||||
workoutTemplateProgramId: 'template-program-1',
|
||||
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
||||
attemptedMeasureOverride: {WorkoutMeasure.reps},
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Active session elapsed time is based on persisted timestamps', () {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: clock.now(),
|
||||
lastPersistedAt: clock.now(),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: '{}',
|
||||
);
|
||||
|
||||
clock.value = clock.value.add(const Duration(seconds: 30));
|
||||
final paused = session.pause(clock.now());
|
||||
expect(paused.elapsedActiveMs, 30000);
|
||||
|
||||
clock.value = clock.value.add(const Duration(minutes: 5));
|
||||
final resumed = paused.resume(clock.now());
|
||||
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 30000);
|
||||
|
||||
clock.value = clock.value.add(const Duration(seconds: 15));
|
||||
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
|
||||
});
|
||||
}
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
createdAt: DateTime.utc(2026, 7, 17),
|
||||
updatedAt: DateTime.utc(2026, 7, 17),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
_FakeClock(this.value);
|
||||
|
||||
DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
@override
|
||||
String newId() {
|
||||
_next += 1;
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutTemplateRepository
|
||||
implements WorkoutTemplateRepository {
|
||||
@override
|
||||
Future<WorkoutTemplate?> findById(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||||
}
|
||||
|
||||
final class _FakeProgramRepository implements ProgramRepository {
|
||||
@override
|
||||
Future<Program?> findById(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<void> save(Program program) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||||
}
|
||||
Reference in New Issue
Block a user