diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index cb59209..60b5e45 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -4181,45 +4181,222 @@ Future _programExerciseFromRow( db.AppDatabase database, db.ProgramExercise row, ) async { + final imageMediaIds = _tryDecodeImageMediaIdsSnapshot( + row.exerciseImageMediaIdsSnapshotJson, + row.exerciseImageMediaIdSnapshot, + ); + final steps = _tryDecodeExerciseStepsSnapshot(row.exerciseStepsSnapshotJson); + final scoreInputMode = _tryScoreInputModeFromDb(row.scoreInputModeSnapshot); + final healthServicesStrategy = + await _tryProgramExerciseHealthServicesStrategySnapshot(database, row.id); + try { + return domain.ProgramExercise( + metadata: _metadataFromRow(row), + programId: row.programId, + sourceExerciseId: row.sourceExerciseId, + position: row.position, + exerciseNameSnapshot: row.exerciseNameSnapshot, + exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot, + exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: imageMediaIds, + exerciseStepsSnapshot: steps, + autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot, + autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride, + exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot, + healthServicesExerciseTypeStrategySnapshot: healthServicesStrategy, + exerciseArchivedSnapshot: row.exerciseArchivedSnapshot, + availableTimeSnapshot: row.availableTimeSnapshot, + availableRepsSnapshot: row.availableRepsSnapshot, + availableScoreSnapshot: row.availableScoreSnapshot, + scoreInputModeSnapshot: scoreInputMode, + 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, + targetScoreTimeMs: row.targetScoreTimeMs, + restSecondsOverride: row.restSecondsOverride, + ); + } on domain.DomainException { + return _repairedProgramExerciseFromRow( + row, + imageMediaIds: imageMediaIds, + steps: steps, + healthServicesStrategy: healthServicesStrategy, + scoreInputMode: scoreInputMode, + ); + } +} + +domain.ProgramExercise _repairedProgramExerciseFromRow( + db.ProgramExercise row, { + required List imageMediaIds, + required List steps, + required List healthServicesStrategy, + required domain.ScoreInputMode scoreInputMode, +}) { + var availableTime = row.availableTimeSnapshot; + var availableReps = row.availableRepsSnapshot; + var availableScore = row.availableScoreSnapshot; + var timeEnabled = row.timeEnabled && availableTime; + var repsEnabled = row.repsEnabled && availableReps; + var scoreEnabled = row.scoreEnabled && availableScore; + if (!timeEnabled && !repsEnabled && !scoreEnabled) { + if (availableReps) { + repsEnabled = true; + } else if (availableTime) { + timeEnabled = true; + } else if (availableScore) { + scoreEnabled = true; + } else { + availableReps = true; + repsEnabled = true; + } + } + final repairedScoreInputMode = scoreEnabled + ? scoreInputMode + : domain.ScoreInputMode.manual; + try { + return _programExerciseFromRepairedValues( + row, + imageMediaIds: imageMediaIds, + steps: steps, + healthServicesStrategy: healthServicesStrategy, + scoreInputMode: repairedScoreInputMode, + availableTime: availableTime, + availableReps: availableReps, + availableScore: availableScore, + timeEnabled: timeEnabled, + repsEnabled: repsEnabled, + scoreEnabled: scoreEnabled, + ); + } on domain.DomainException { + return _programExerciseFromRepairedValues( + row, + imageMediaIds: imageMediaIds, + steps: const [], + healthServicesStrategy: healthServicesStrategy, + scoreInputMode: repairedScoreInputMode, + availableTime: availableTime, + availableReps: availableReps, + availableScore: availableScore, + timeEnabled: timeEnabled, + repsEnabled: repsEnabled, + scoreEnabled: scoreEnabled, + ); + } +} + +domain.ProgramExercise _programExerciseFromRepairedValues( + db.ProgramExercise row, { + required List imageMediaIds, + required List steps, + required List healthServicesStrategy, + required domain.ScoreInputMode scoreInputMode, + required bool availableTime, + required bool availableReps, + required bool availableScore, + required bool timeEnabled, + required bool repsEnabled, + required bool scoreEnabled, +}) { return domain.ProgramExercise( metadata: _metadataFromRow(row), programId: row.programId, sourceExerciseId: row.sourceExerciseId, - position: row.position, - exerciseNameSnapshot: row.exerciseNameSnapshot, + position: row.position < 0 ? 0 : row.position, + exerciseNameSnapshot: row.exerciseNameSnapshot.trim().isEmpty + ? row.id + : row.exerciseNameSnapshot, exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot, - exerciseImageMediaIdsSnapshot: _decodeImageMediaIdsSnapshot( - row.exerciseImageMediaIdsSnapshotJson, - row.exerciseImageMediaIdSnapshot, - ), - exerciseStepsSnapshot: _decodeExerciseStepsSnapshot( - row.exerciseStepsSnapshotJson, - ), + exerciseImageMediaIdsSnapshot: imageMediaIds, + exerciseStepsSnapshot: steps, autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot, autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride, exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot, - healthServicesExerciseTypeStrategySnapshot: - await _programExerciseHealthServicesStrategySnapshot(database, row.id), + healthServicesExerciseTypeStrategySnapshot: healthServicesStrategy, exerciseArchivedSnapshot: row.exerciseArchivedSnapshot, - availableTimeSnapshot: row.availableTimeSnapshot, - availableRepsSnapshot: row.availableRepsSnapshot, - availableScoreSnapshot: row.availableScoreSnapshot, - scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot), + availableTimeSnapshot: availableTime, + availableRepsSnapshot: availableReps, + availableScoreSnapshot: availableScore, + scoreInputModeSnapshot: scoreInputMode, 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, - targetScoreTimeMs: row.targetScoreTimeMs, - restSecondsOverride: row.restSecondsOverride, + setsCount: row.setsCount <= 0 ? 1 : row.setsCount, + timeEnabled: timeEnabled, + repsEnabled: repsEnabled, + scoreEnabled: scoreEnabled, + targetTimeSeconds: timeEnabled + ? _positiveIntOrNull(row.targetTimeSeconds) + : null, + targetReps: repsEnabled ? _positiveIntOrNull(row.targetReps) : null, + targetScore: scoreEnabled && scoreInputMode == domain.ScoreInputMode.manual + ? _nonNegativeDoubleOrNull(row.targetScore) + : null, + targetScoreTimeMs: + scoreEnabled && scoreInputMode == domain.ScoreInputMode.stopwatch + ? _positiveIntOrNull(row.targetScoreTimeMs) + : null, + restSecondsOverride: _nonNegativeIntOrNull(row.restSecondsOverride), ); } +List _tryDecodeImageMediaIdsSnapshot( + String? encoded, + String? fallbackImageMediaId, +) { + try { + return _decodeImageMediaIdsSnapshot(encoded, fallbackImageMediaId); + } on Object { + return fallbackImageMediaId == null ? const [] : [fallbackImageMediaId]; + } +} + +List _tryDecodeExerciseStepsSnapshot(String? encoded) { + try { + return _decodeExerciseStepsSnapshot(encoded); + } on Object { + return const []; + } +} + +domain.ScoreInputMode _tryScoreInputModeFromDb(String value) { + try { + return _scoreInputModeFromDb(value); + } on domain.DomainException { + return domain.ScoreInputMode.manual; + } +} + +int? _positiveIntOrNull(int? value) { + return value != null && value > 0 ? value : null; +} + +int? _nonNegativeIntOrNull(int? value) { + return value != null && value >= 0 ? value : null; +} + +double? _nonNegativeDoubleOrNull(double? value) { + return value != null && value >= 0 ? value : null; +} + +Future> +_tryProgramExerciseHealthServicesStrategySnapshot( + db.AppDatabase database, + String id, +) async { + try { + return _programExerciseHealthServicesStrategySnapshot(database, id); + } on Object { + return domain.legacyHealthServicesExerciseTypeStrategy; + } +} + Future> _programExerciseHealthServicesStrategySnapshot( db.AppDatabase database, diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 826d7a5..888ee93 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -1750,6 +1750,64 @@ CREATE TABLE pending_share_actions ( expect(programs.single.exercises.single.targetReps, 8); }); + test( + 'program list tolerates invalid snapshots after source exercise deletion', + () async { + final now = DateTime.utc(2026, 7, 30, 18); + final exercise = Exercise( + metadata: _metadata('exercise-deleted-invalid-snapshot', now), + name: 'Concours de tirs', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreLabel: 'Score', + scoreUnit: 'pts', + ); + await exerciseRepository.save(exercise); + await programRepository.save( + Program( + metadata: _metadata('program-invalid-snapshot', now), + name: 'Programme snapshot legacy', + defaultRestSeconds: 30, + exercises: [ + ProgramExercise.snapshotFromExercise( + metadata: _metadata('program-exercise-invalid-snapshot', now), + programId: 'program-invalid-snapshot', + exercise: exercise, + position: 0, + setsCount: 2, + enabledMeasures: const {WorkoutMeasure.score}, + ), + ], + ), + ); + await exerciseRepository.save( + exercise.copyWith( + metadata: exercise.metadata.markDeleted( + now.add(const Duration(minutes: 1)), + ), + ), + ); + await database.customStatement('PRAGMA ignore_check_constraints = ON'); + await database.customStatement( + 'UPDATE program_exercises ' + 'SET available_reps_snapshot = 1, available_score_snapshot = 0, ' + 'score_label_snapshot = NULL, score_unit_snapshot = NULL ' + "WHERE id = 'program-exercise-invalid-snapshot'", + ); + await database.customStatement('PRAGMA ignore_check_constraints = OFF'); + + final programs = await programRepository.listActive(); + + expect(programs, hasLength(1)); + final restored = programs.single.exercises.single; + expect(restored.exerciseNameSnapshot, 'Concours de tirs'); + expect(restored.sourceExerciseId, exercise.metadata.id); + expect(restored.repsEnabled, isTrue); + expect(restored.scoreEnabled, isFalse); + }, + ); + test( 'starter program and workout template snapshot exercise steps', () async {