diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 81dd29c..38fca2b 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -4160,6 +4160,10 @@ final class WatchSessionProjectionProjector { currentStep: currentStep, timers: allTimers, ); + final healthServicesExerciseTypeStrategy = + _sessionHealthServicesExerciseTypeStrategy( + session.resolvedTemplateSnapshotJson, + ); return WatchSessionProjection( deviceSessionId: session.metadata.id, @@ -4220,8 +4224,7 @@ final class WatchSessionProjectionProjector { manualScoreTargetLabel: manualScoreProjection?.targetLabel, manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue, manualScoreScope: manualScoreProjection?.scope, - healthServicesExerciseTypeStrategy: - snapshot.healthServicesExerciseTypeStrategy, + healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy, ); } @@ -7216,14 +7219,73 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({ scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, setsCount: exercise['setsCount'] as int? ?? 0, restSeconds: exercise['restSecondsOverride'] as int? ?? 0, - healthServicesExerciseTypeStrategy: _stringListFromSnapshot( - exercise['healthServicesExerciseTypeStrategy'], - ), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: autoStartNextTimedStepEffective, ); } +const _legacyHealthServicesExerciseTypeStrategy = [ + 'RUNNING', + 'WALKING', + 'HIGH_INTENSITY_INTERVAL_TRAINING', + 'WORKOUT', +]; + +const _knownHealthServicesExerciseTypeNames = { + 'RUNNING', + 'WALKING', + 'HIGH_INTENSITY_INTERVAL_TRAINING', + 'WORKOUT', +}; + +List _sessionHealthServicesExerciseTypeStrategy( + String resolvedTemplateSnapshotJson, +) { + try { + final decoded = + jsonDecode(resolvedTemplateSnapshotJson) as Map; + final programs = (decoded['programs'] as List? ?? const []); + final output = []; + void add(String type) { + if (_knownHealthServicesExerciseTypeNames.contains(type) && + !output.contains(type)) { + output.add(type); + } + } + + for (final rawProgram in programs) { + if (rawProgram is! Map) { + continue; + } + final program = Map.from(rawProgram); + final programSnapshotJson = program['programSnapshotJson'] as String?; + if (programSnapshotJson == null) { + continue; + } + final programSnapshot = + jsonDecode(programSnapshotJson) as Map; + final exercises = + (programSnapshot['exercises'] as List? ?? const []); + for (final rawExercise in exercises) { + if (rawExercise is! Map) { + continue; + } + final exercise = Map.from(rawExercise); + for (final type in _stringListFromSnapshot( + exercise['healthServicesExerciseTypeStrategy'], + )) { + add(type); + } + } + } + return output.isEmpty + ? _legacyHealthServicesExerciseTypeStrategy + : List.unmodifiable(output); + } on Object { + return _legacyHealthServicesExerciseTypeStrategy; + } +} + bool? _templateStepChainingOverride( Map resolvedTemplateSnapshot, { required String workoutTemplateProgramId, @@ -7439,9 +7501,6 @@ Map _exerciseSnapshotsById( scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, setsCount: exercise['setsCount'] as int? ?? 0, restSeconds: exercise['restSecondsOverride'] as int? ?? 0, - healthServicesExerciseTypeStrategy: _stringListFromSnapshot( - exercise['healthServicesExerciseTypeStrategy'], - ), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: (exercise['autoStartNextTimedStepOverride'] as bool?) ?? @@ -7472,7 +7531,6 @@ final class _ResolvedExerciseSnapshot { this.scoreUnitSnapshot, required this.setsCount, required this.restSeconds, - this.healthServicesExerciseTypeStrategy = const [], this.steps = const [], this.autoStartNextTimedStepEffective = true, }); @@ -7494,7 +7552,6 @@ final class _ResolvedExerciseSnapshot { final String? scoreUnitSnapshot; final int setsCount; final int restSeconds; - final List healthServicesExerciseTypeStrategy; final List steps; final bool autoStartNextTimedStepEffective; } diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 217998d..cb59209 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -524,12 +524,17 @@ final class DriftLocalSyncChangeRepository .insertOnConflictUpdate(_programCompanion(program)); await _writeProgramStarterMetadata(database, program); for (final exercise in program.exercises) { + final exerciseToStore = await _programExerciseWithResolvableSource( + exercise, + ); await database .into(database.programExercises) - .insertOnConflictUpdate(_programExerciseCompanion(exercise)); + .insertOnConflictUpdate( + _programExerciseCompanion(exerciseToStore), + ); await _writeProgramExerciseHealthServicesStrategySnapshot( database, - exercise, + exerciseToStore, ); } }); @@ -853,6 +858,53 @@ WHERE id IN (${List.filled(ids.length, '?').join(', ')}) ], ); } + + Future _programExerciseWithResolvableSource( + domain.ProgramExercise exercise, + ) async { + final sourceExerciseId = exercise.sourceExerciseId; + if (sourceExerciseId == null) { + return exercise; + } + final sourceRow = await (database.select( + database.exercises, + )..where((table) => table.id.equals(sourceExerciseId))).getSingleOrNull(); + if (sourceRow != null) { + return exercise; + } + return domain.ProgramExercise( + metadata: exercise.metadata, + programId: exercise.programId, + sourceExerciseId: null, + position: exercise.position, + exerciseNameSnapshot: exercise.exerciseNameSnapshot, + exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot, + exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot, + exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot, + healthServicesExerciseTypeStrategySnapshot: + exercise.healthServicesExerciseTypeStrategySnapshot, + exerciseStepsSnapshot: exercise.exerciseStepsSnapshot, + autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStepSnapshot, + autoStartNextTimedStepOverride: exercise.autoStartNextTimedStepOverride, + exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot, + availableTimeSnapshot: exercise.availableTimeSnapshot, + availableRepsSnapshot: exercise.availableRepsSnapshot, + availableScoreSnapshot: exercise.availableScoreSnapshot, + scoreInputModeSnapshot: exercise.scoreInputModeSnapshot, + scoreLabelSnapshot: exercise.scoreLabelSnapshot, + scoreUnitSnapshot: exercise.scoreUnitSnapshot, + setsCount: exercise.setsCount, + timeEnabled: exercise.timeEnabled, + repsEnabled: exercise.repsEnabled, + scoreEnabled: exercise.scoreEnabled, + targetTimeSeconds: exercise.targetTimeSeconds, + targetReps: exercise.targetReps, + targetScore: exercise.targetScore, + targetScoreTimeMs: exercise.targetScoreTimeMs, + restSecondsOverride: exercise.restSecondsOverride, + ); + } } final class DriftShareInboxRepository implements ShareInboxRepository { diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index f16a3a2..d8e07ae 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -54,28 +54,27 @@ void main() { expect(projection.dominantTimer, isNull); }); - test( - 'projects Health Services exercise type strategy from snapshot', - () async { - final repository = _FakeActiveSessionRepository() - ..session = _session( - healthServicesExerciseTypeStrategy: const [ - 'RUNNING', - 'HIGH_INTENSITY_INTERVAL_TRAINING', - 'WORKOUT', - ], - ); - final projector = _projector(repository, _clock()); + test('projects session-level Health Services exercise strategy', () async { + final repository = _FakeActiveSessionRepository() + ..session = _session( + healthServicesExerciseTypeStrategy: const ['WORKOUT'], + secondExerciseName: 'Dribble', + secondExerciseHealthServicesExerciseTypeStrategy: const [ + 'RUNNING', + 'HIGH_INTENSITY_INTERVAL_TRAINING', + 'WORKOUT', + ], + ); + final projector = _projector(repository, _clock()); - final projection = await projector.project(revision: 1); + final projection = await projector.project(revision: 1); - expect(projection.healthServicesExerciseTypeStrategy, [ - 'RUNNING', - 'HIGH_INTENSITY_INTERVAL_TRAINING', - 'WORKOUT', - ]); - }, - ); + expect(projection.healthServicesExerciseTypeStrategy, [ + 'WORKOUT', + 'RUNNING', + 'HIGH_INTENSITY_INTERVAL_TRAINING', + ]); + }); test('projects running with step timer before stopwatch score', () async { final now = DateTime.utc(2026, 7, 25, 12); @@ -611,6 +610,7 @@ ActiveWorkoutSession _session({ List steps = const [], String? secondExerciseName, List healthServicesExerciseTypeStrategy = const [], + List secondExerciseHealthServicesExerciseTypeStrategy = const [], }) { final exerciseSnapshot = { 'id': 'exercise-snapshot-1', @@ -642,6 +642,8 @@ ActiveWorkoutSession _session({ 'scoreInputModeSnapshot': ScoreInputMode.manual.name, 'exerciseStepsSnapshot': const [], 'autoStartNextTimedStepSnapshot': true, + 'healthServicesExerciseTypeStrategy': + secondExerciseHealthServicesExerciseTypeStrategy, }; return ActiveWorkoutSession( metadata: _metadata('session-1'), diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 775321b..826d7a5 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -951,6 +951,60 @@ CREATE TABLE pending_share_actions ( expect(template!.tags, isEmpty); }); + test( + 'local sync pull keeps program loadable when source exercise is absent', + () async { + final now = DateTime.utc(2026, 7, 23, 11); + + final applied = await syncChangeRepository.applyRemoteItem( + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'remote-program-missing-exercise', + serverId: 'server-program-missing-exercise', + schemaVersion: 1, + clientUpdatedAt: now, + serverUpdatedAt: now, + deletedAt: null, + payload: const { + 'id': 'remote-program-missing-exercise', + 'name': 'Remote program', + 'defaultRestSeconds': 30, + 'exercises': [ + { + 'id': 'remote-program-exercise-missing-source', + 'sourceExerciseId': 'remote-exercise-deleted', + 'position': 0, + 'exerciseNameSnapshot': 'Remote deleted exercise', + 'availableTimeSnapshot': false, + 'availableRepsSnapshot': true, + 'availableScoreSnapshot': false, + 'setsCount': 2, + 'timeEnabled': false, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': 15, + }, + ], + }, + ), + ); + + final program = await programRepository.findById( + 'remote-program-missing-exercise', + ); + + expect(applied, isTrue); + expect(program, isNotNull); + expect(program!.exercises, hasLength(1)); + expect(program.exercises.single.sourceExerciseId, isNull); + expect( + program.exercises.single.exerciseNameSnapshot, + 'Remote deleted exercise', + ); + expect(program.exercises.single.targetReps, 15); + }, + ); + test('local backup export includes tags and full workout history', () async { final now = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( @@ -1644,6 +1698,58 @@ CREATE TABLE pending_share_actions ( expect(await exerciseRepository.findById(exercise.metadata.id), isNotNull); }); + test('program remains loadable after source exercise deletion', () async { + final now = DateTime.utc(2026, 7, 23, 9); + final exercise = Exercise( + metadata: _metadata('exercise-deleted-source', now), + name: 'Tirs en course', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 8, + ); + await exerciseRepository.save(exercise); + await programRepository.save( + Program( + metadata: _metadata('program-deleted-source', now), + name: 'Programme source supprimée', + defaultRestSeconds: 30, + exercises: [ + ProgramExercise.snapshotFromExercise( + metadata: _metadata('program-exercise-deleted-source', now), + programId: 'program-deleted-source', + exercise: exercise, + position: 0, + setsCount: 3, + enabledMeasures: const {WorkoutMeasure.reps}, + ), + ], + ), + ); + + await exerciseRepository.save( + exercise.copyWith( + metadata: exercise.metadata.markDeleted( + now.add(const Duration(minutes: 1)), + ), + ), + ); + + final programs = await programRepository.listActive(); + + expect(programs, hasLength(1)); + expect(programs.single.name, 'Programme source supprimée'); + expect( + programs.single.exercises.single.sourceExerciseId, + exercise.metadata.id, + ); + expect( + programs.single.exercises.single.exerciseNameSnapshot, + 'Tirs en course', + ); + expect(programs.single.exercises.single.targetReps, 8); + }); + test( 'starter program and workout template snapshot exercise steps', () async {