fix(core): fusionne la strategie Health Services au niveau seance et nullifie sourceExerciseId au pull si absent

Complement #183 : fusion de la strategie Health Services au niveau seance pour eviter que le premier exercice fige tout le choix de strategie sur la seance.
Fix #185 : nullification defensive de sourceExerciseId au pull quand l'exercice source est absent, pour ne plus casser le chargement des programmes apres suppression d'un exercice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 17:12:03 +02:00
parent c43b93ef99
commit 326bd1893c
4 changed files with 249 additions and 32 deletions

View File

@ -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<String> _sessionHealthServicesExerciseTypeStrategy(
String resolvedTemplateSnapshotJson,
) {
try {
final decoded =
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
final output = <String>[];
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<String, dynamic>.from(rawProgram);
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 (final rawExercise in exercises) {
if (rawExercise is! Map) {
continue;
}
final exercise = Map<String, dynamic>.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<String, dynamic> resolvedTemplateSnapshot, {
required String workoutTemplateProgramId,
@ -7439,9 +7501,6 @@ Map<String, _ResolvedExerciseSnapshot> _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<String> healthServicesExerciseTypeStrategy;
final List<ExerciseStep> steps;
final bool autoStartNextTimedStepEffective;
}

View File

@ -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<domain.ProgramExercise> _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 {

View File

@ -54,12 +54,12 @@ void main() {
expect(projection.dominantTimer, isNull);
});
test(
'projects Health Services exercise type strategy from snapshot',
() async {
test('projects session-level Health Services exercise strategy', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(
healthServicesExerciseTypeStrategy: const [
healthServicesExerciseTypeStrategy: const ['WORKOUT'],
secondExerciseName: 'Dribble',
secondExerciseHealthServicesExerciseTypeStrategy: const [
'RUNNING',
'HIGH_INTENSITY_INTERVAL_TRAINING',
'WORKOUT',
@ -70,12 +70,11 @@ void main() {
final projection = await projector.project(revision: 1);
expect(projection.healthServicesExerciseTypeStrategy, [
'WORKOUT',
'RUNNING',
'HIGH_INTENSITY_INTERVAL_TRAINING',
'WORKOUT',
]);
},
);
});
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<ExerciseStep> steps = const [],
String? secondExerciseName,
List<String> healthServicesExerciseTypeStrategy = const [],
List<String> 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'),

View File

@ -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 {