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:
@ -4160,6 +4160,10 @@ final class WatchSessionProjectionProjector {
|
|||||||
currentStep: currentStep,
|
currentStep: currentStep,
|
||||||
timers: allTimers,
|
timers: allTimers,
|
||||||
);
|
);
|
||||||
|
final healthServicesExerciseTypeStrategy =
|
||||||
|
_sessionHealthServicesExerciseTypeStrategy(
|
||||||
|
session.resolvedTemplateSnapshotJson,
|
||||||
|
);
|
||||||
|
|
||||||
return WatchSessionProjection(
|
return WatchSessionProjection(
|
||||||
deviceSessionId: session.metadata.id,
|
deviceSessionId: session.metadata.id,
|
||||||
@ -4220,8 +4224,7 @@ final class WatchSessionProjectionProjector {
|
|||||||
manualScoreTargetLabel: manualScoreProjection?.targetLabel,
|
manualScoreTargetLabel: manualScoreProjection?.targetLabel,
|
||||||
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
|
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
|
||||||
manualScoreScope: manualScoreProjection?.scope,
|
manualScoreScope: manualScoreProjection?.scope,
|
||||||
healthServicesExerciseTypeStrategy:
|
healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy,
|
||||||
snapshot.healthServicesExerciseTypeStrategy,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7216,14 +7219,73 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
|
|||||||
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
||||||
setsCount: exercise['setsCount'] as int? ?? 0,
|
setsCount: exercise['setsCount'] as int? ?? 0,
|
||||||
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||||||
healthServicesExerciseTypeStrategy: _stringListFromSnapshot(
|
|
||||||
exercise['healthServicesExerciseTypeStrategy'],
|
|
||||||
),
|
|
||||||
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
||||||
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
|
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(
|
bool? _templateStepChainingOverride(
|
||||||
Map<String, dynamic> resolvedTemplateSnapshot, {
|
Map<String, dynamic> resolvedTemplateSnapshot, {
|
||||||
required String workoutTemplateProgramId,
|
required String workoutTemplateProgramId,
|
||||||
@ -7439,9 +7501,6 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
|||||||
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
||||||
setsCount: exercise['setsCount'] as int? ?? 0,
|
setsCount: exercise['setsCount'] as int? ?? 0,
|
||||||
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||||||
healthServicesExerciseTypeStrategy: _stringListFromSnapshot(
|
|
||||||
exercise['healthServicesExerciseTypeStrategy'],
|
|
||||||
),
|
|
||||||
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
||||||
autoStartNextTimedStepEffective:
|
autoStartNextTimedStepEffective:
|
||||||
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
|
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
|
||||||
@ -7472,7 +7531,6 @@ final class _ResolvedExerciseSnapshot {
|
|||||||
this.scoreUnitSnapshot,
|
this.scoreUnitSnapshot,
|
||||||
required this.setsCount,
|
required this.setsCount,
|
||||||
required this.restSeconds,
|
required this.restSeconds,
|
||||||
this.healthServicesExerciseTypeStrategy = const [],
|
|
||||||
this.steps = const [],
|
this.steps = const [],
|
||||||
this.autoStartNextTimedStepEffective = true,
|
this.autoStartNextTimedStepEffective = true,
|
||||||
});
|
});
|
||||||
@ -7494,7 +7552,6 @@ final class _ResolvedExerciseSnapshot {
|
|||||||
final String? scoreUnitSnapshot;
|
final String? scoreUnitSnapshot;
|
||||||
final int setsCount;
|
final int setsCount;
|
||||||
final int restSeconds;
|
final int restSeconds;
|
||||||
final List<String> healthServicesExerciseTypeStrategy;
|
|
||||||
final List<ExerciseStep> steps;
|
final List<ExerciseStep> steps;
|
||||||
final bool autoStartNextTimedStepEffective;
|
final bool autoStartNextTimedStepEffective;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -524,12 +524,17 @@ final class DriftLocalSyncChangeRepository
|
|||||||
.insertOnConflictUpdate(_programCompanion(program));
|
.insertOnConflictUpdate(_programCompanion(program));
|
||||||
await _writeProgramStarterMetadata(database, program);
|
await _writeProgramStarterMetadata(database, program);
|
||||||
for (final exercise in program.exercises) {
|
for (final exercise in program.exercises) {
|
||||||
|
final exerciseToStore = await _programExerciseWithResolvableSource(
|
||||||
|
exercise,
|
||||||
|
);
|
||||||
await database
|
await database
|
||||||
.into(database.programExercises)
|
.into(database.programExercises)
|
||||||
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
|
.insertOnConflictUpdate(
|
||||||
|
_programExerciseCompanion(exerciseToStore),
|
||||||
|
);
|
||||||
await _writeProgramExerciseHealthServicesStrategySnapshot(
|
await _writeProgramExerciseHealthServicesStrategySnapshot(
|
||||||
database,
|
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 {
|
final class DriftShareInboxRepository implements ShareInboxRepository {
|
||||||
|
|||||||
@ -54,12 +54,12 @@ void main() {
|
|||||||
expect(projection.dominantTimer, isNull);
|
expect(projection.dominantTimer, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('projects session-level Health Services exercise strategy', () async {
|
||||||
'projects Health Services exercise type strategy from snapshot',
|
|
||||||
() async {
|
|
||||||
final repository = _FakeActiveSessionRepository()
|
final repository = _FakeActiveSessionRepository()
|
||||||
..session = _session(
|
..session = _session(
|
||||||
healthServicesExerciseTypeStrategy: const [
|
healthServicesExerciseTypeStrategy: const ['WORKOUT'],
|
||||||
|
secondExerciseName: 'Dribble',
|
||||||
|
secondExerciseHealthServicesExerciseTypeStrategy: const [
|
||||||
'RUNNING',
|
'RUNNING',
|
||||||
'HIGH_INTENSITY_INTERVAL_TRAINING',
|
'HIGH_INTENSITY_INTERVAL_TRAINING',
|
||||||
'WORKOUT',
|
'WORKOUT',
|
||||||
@ -70,12 +70,11 @@ void main() {
|
|||||||
final projection = await projector.project(revision: 1);
|
final projection = await projector.project(revision: 1);
|
||||||
|
|
||||||
expect(projection.healthServicesExerciseTypeStrategy, [
|
expect(projection.healthServicesExerciseTypeStrategy, [
|
||||||
|
'WORKOUT',
|
||||||
'RUNNING',
|
'RUNNING',
|
||||||
'HIGH_INTENSITY_INTERVAL_TRAINING',
|
'HIGH_INTENSITY_INTERVAL_TRAINING',
|
||||||
'WORKOUT',
|
|
||||||
]);
|
]);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('projects running with step timer before stopwatch score', () async {
|
test('projects running with step timer before stopwatch score', () async {
|
||||||
final now = DateTime.utc(2026, 7, 25, 12);
|
final now = DateTime.utc(2026, 7, 25, 12);
|
||||||
@ -611,6 +610,7 @@ ActiveWorkoutSession _session({
|
|||||||
List<ExerciseStep> steps = const [],
|
List<ExerciseStep> steps = const [],
|
||||||
String? secondExerciseName,
|
String? secondExerciseName,
|
||||||
List<String> healthServicesExerciseTypeStrategy = const [],
|
List<String> healthServicesExerciseTypeStrategy = const [],
|
||||||
|
List<String> secondExerciseHealthServicesExerciseTypeStrategy = const [],
|
||||||
}) {
|
}) {
|
||||||
final exerciseSnapshot = {
|
final exerciseSnapshot = {
|
||||||
'id': 'exercise-snapshot-1',
|
'id': 'exercise-snapshot-1',
|
||||||
@ -642,6 +642,8 @@ ActiveWorkoutSession _session({
|
|||||||
'scoreInputModeSnapshot': ScoreInputMode.manual.name,
|
'scoreInputModeSnapshot': ScoreInputMode.manual.name,
|
||||||
'exerciseStepsSnapshot': const [],
|
'exerciseStepsSnapshot': const [],
|
||||||
'autoStartNextTimedStepSnapshot': true,
|
'autoStartNextTimedStepSnapshot': true,
|
||||||
|
'healthServicesExerciseTypeStrategy':
|
||||||
|
secondExerciseHealthServicesExerciseTypeStrategy,
|
||||||
};
|
};
|
||||||
return ActiveWorkoutSession(
|
return ActiveWorkoutSession(
|
||||||
metadata: _metadata('session-1'),
|
metadata: _metadata('session-1'),
|
||||||
|
|||||||
@ -951,6 +951,60 @@ CREATE TABLE pending_share_actions (
|
|||||||
expect(template!.tags, isEmpty);
|
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 {
|
test('local backup export includes tags and full workout history', () async {
|
||||||
final now = DateTime.utc(2026, 7, 22, 10);
|
final now = DateTime.utc(2026, 7, 22, 10);
|
||||||
await exerciseRepository.save(
|
await exerciseRepository.save(
|
||||||
@ -1644,6 +1698,58 @@ CREATE TABLE pending_share_actions (
|
|||||||
expect(await exerciseRepository.findById(exercise.metadata.id), isNotNull);
|
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(
|
test(
|
||||||
'starter program and workout template snapshot exercise steps',
|
'starter program and workout template snapshot exercise steps',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user