feat(data): édition ponctuelle des séries en séance (ticket #21)

Étend le modèle Drift (tables.dart, app_database.dart/.g.dart) et la
couche application (entités, use cases, repositories) pour supporter
l'édition ponctuelle des séries pendant l'exécution d'une séance, sans
modifier le modèle/séance-modèle. build_runner OK, flutter analyze
propre, 30/30 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:19:02 +02:00
parent 7eb8f260e0
commit abfbb64de2
7 changed files with 621 additions and 15 deletions

View File

@ -717,11 +717,117 @@ final class ActiveWorkoutSessionUseCases {
actualScore: actualScore,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
status: SetResultStatus.completed,
);
await sessionRepository.saveSetResult(result);
return result;
}
Future<ActiveSetResult> upsertSetResultAtPosition({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required SetResultStatus status,
int? actualTimeMs,
int? actualReps,
double? actualScore,
String? scoreLabelSnapshot,
String? scoreUnitSnapshot,
String? note,
}) async {
final session = await _requiredSession(sessionId);
_ensureEditablePastPosition(
session: session,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final snapshot = _findSetSnapshot(
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (snapshot == null) {
throw const DomainException(
'Set position not found in session snapshot.',
);
}
final existingResults = await sessionRepository.listSetResults(sessionId);
ActiveSetResult? existing;
for (final result in existingResults) {
if (result.programIndex == programIndex &&
result.exerciseIndex == exerciseIndex &&
result.setIndex == setIndex) {
existing = result;
break;
}
}
final now = clock.now();
final isSkipped = status == SetResultStatus.skipped;
final upserted = ActiveSetResult(
metadata: existing == null
? _newMetadata(ids, originDeviceId, now)
: existing.metadata.touch(now),
activeWorkoutSessionId: sessionId,
programSnapshotId: snapshot.programSnapshotId,
exerciseSnapshotId: snapshot.exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
startedAt: existing?.startedAt,
completedAt: isSkipped ? null : now,
actualTimeMs: isSkipped ? null : actualTimeMs,
actualReps: isSkipped ? null : actualReps,
actualScore: isSkipped ? null : actualScore,
scoreLabelSnapshot: isSkipped
? null
: scoreLabelSnapshot ?? snapshot.scoreLabelSnapshot,
scoreUnitSnapshot: isSkipped
? null
: scoreUnitSnapshot ?? snapshot.scoreUnitSnapshot,
note: note,
status: status,
);
await sessionRepository.saveSetResult(upserted);
return upserted;
}
Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
final session = await _requiredSession(sessionId);
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
final results = await sessionRepository.listSetResults(sessionId);
final resultsByPosition = {
for (final result in results)
_positionKey(
result.programIndex,
result.exerciseIndex,
result.setIndex,
): result,
};
return snapshots.map((snapshot) {
final result =
resultsByPosition[_positionKey(
snapshot.programIndex,
snapshot.exerciseIndex,
snapshot.setIndex,
)];
return SetResultPositionState(
programIndex: snapshot.programIndex,
exerciseIndex: snapshot.exerciseIndex,
setIndex: snapshot.setIndex,
status: result == null
? SetPositionStatus.pending
: switch (result.status) {
SetResultStatus.completed => SetPositionStatus.completed,
SetResultStatus.skipped => SetPositionStatus.skipped,
},
result: result,
);
}).toList();
}
Future<ActiveWorkoutSession> updateProgress({
required String sessionId,
required int programIndex,
@ -921,6 +1027,7 @@ final class CloseWorkoutSessionUseCase {
'actualScore': result.actualScore,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'status': result.status.name,
},
)
.toList(),
@ -945,6 +1052,24 @@ final class WorkoutHistoryUseCases {
Future<void> delete(String id) => repository.delete(id, clock.now());
}
enum SetPositionStatus { pending, completed, skipped }
final class SetResultPositionState {
const SetResultPositionState({
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.status,
this.result,
});
final int programIndex;
final int exerciseIndex;
final int setIndex;
final SetPositionStatus status;
final ActiveSetResult? result;
}
void _validateOverrideTargets(
String programSnapshotJson,
WorkoutTemplateExerciseOverrideConfig input,
@ -973,6 +1098,132 @@ void _validateOverrideTargets(
}
}
void _ensureEditablePastPosition({
required ActiveWorkoutSession session,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
final comparison = _comparePositions(
programIndex,
exerciseIndex,
setIndex,
session.currentProgramIndex,
session.currentExerciseIndex,
session.currentSetIndex,
);
if (comparison >= 0) {
throw const DomainException(
'Only past set results can be edited outside the normal execution flow.',
);
}
}
int _comparePositions(
int leftProgram,
int leftExercise,
int leftSet,
int rightProgram,
int rightExercise,
int rightSet,
) {
final programComparison = leftProgram.compareTo(rightProgram);
if (programComparison != 0) {
return programComparison;
}
final exerciseComparison = leftExercise.compareTo(rightExercise);
if (exerciseComparison != 0) {
return exerciseComparison;
}
return leftSet.compareTo(rightSet);
}
String _positionKey(int programIndex, int exerciseIndex, int setIndex) {
return '$programIndex:$exerciseIndex:$setIndex';
}
_SetPositionSnapshot? _findSetSnapshot({
required String resolvedTemplateSnapshotJson,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
for (final snapshot in _listSetSnapshots(resolvedTemplateSnapshotJson)) {
if (snapshot.programIndex == programIndex &&
snapshot.exerciseIndex == exerciseIndex &&
snapshot.setIndex == setIndex) {
return snapshot;
}
}
return null;
}
List<_SetPositionSnapshot> _listSetSnapshots(
String resolvedTemplateSnapshotJson,
) {
final decoded =
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
final snapshots = <_SetPositionSnapshot>[];
for (var programIndex = 0; programIndex < programs.length; programIndex++) {
final program = programs[programIndex] as Map<String, dynamic>;
final programSnapshotId =
program['id'] as String? ?? 'program-$programIndex';
final programSnapshotJson = program['programSnapshotJson'] as String?;
if (programSnapshotJson == null) {
continue;
}
final programSnapshot =
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises =
(programSnapshot['exercises'] as List<dynamic>? ?? const []);
for (
var exerciseIndex = 0;
exerciseIndex < exercises.length;
exerciseIndex++
) {
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
final exerciseSnapshotId =
exercise['id'] as String? ?? 'exercise-$exerciseIndex';
final setsCount = exercise['setsCount'] as int? ?? 0;
for (var setIndex = 0; setIndex < setsCount; setIndex++) {
snapshots.add(
_SetPositionSnapshot(
programSnapshotId: programSnapshotId,
exerciseSnapshotId: exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
),
);
}
}
}
return snapshots;
}
final class _SetPositionSnapshot {
const _SetPositionSnapshot({
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
});
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
}
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
required String historyId,
required List<ActiveSetResult> results,
@ -1012,6 +1263,7 @@ List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot,
startedAt: result.startedAt,
completedAt: result.completedAt,
status: result.status,
);
}).toList();
}