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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user