feat(core): consolide le chainage type metier d'exercice -> strategie Health Services -> payload montre -> consommation Kotlin

Finalise #183 : modele/persistance des types metier d'exercice, mapping vers la strategie Health Services, propagation du payload dans le contrat watch bridge et consommation cote Kotlin montre. Perimetre complet QA vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:17:31 +02:00
parent 30e657a67f
commit 2ae716206a
10 changed files with 444 additions and 22 deletions

View File

@ -3542,7 +3542,11 @@ bool _hasSameWatchCommandRevisionState(
left.manualScoreTargetValue == right.manualScoreTargetValue &&
left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
left.manualScoreScope == right.manualScoreScope;
left.manualScoreScope == right.manualScoreScope &&
_listEquals(
left.healthServicesExerciseTypeStrategy,
right.healthServicesExerciseTypeStrategy,
);
}
bool _hasSameWatchCommandRevisionTimerListState(
@ -4216,6 +4220,8 @@ final class WatchSessionProjectionProjector {
manualScoreTargetLabel: manualScoreProjection?.targetLabel,
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
manualScoreScope: manualScoreProjection?.scope,
healthServicesExerciseTypeStrategy:
snapshot.healthServicesExerciseTypeStrategy,
);
}
@ -7052,6 +7058,13 @@ ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
};
}
List<String> _stringListFromSnapshot(Object? value) {
if (value is! List) {
return const [];
}
return value.whereType<String>().toList(growable: false);
}
_SetPositionSnapshot? _findSetSnapshot({
required String resolvedTemplateSnapshotJson,
required int programIndex,
@ -7203,6 +7216,9 @@ _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,
);
@ -7423,6 +7439,9 @@ 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?) ??
@ -7453,6 +7472,7 @@ final class _ResolvedExerciseSnapshot {
this.scoreUnitSnapshot,
required this.setsCount,
required this.restSeconds,
this.healthServicesExerciseTypeStrategy = const [],
this.steps = const [],
this.autoStartNextTimedStepEffective = true,
});
@ -7474,6 +7494,7 @@ 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

@ -33,6 +33,17 @@ enum BusinessExerciseType {
recovery,
}
enum HealthServicesExerciseType {
running('RUNNING'),
walking('WALKING'),
highIntensityIntervalTraining('HIGH_INTENSITY_INTERVAL_TRAINING'),
workout('WORKOUT');
const HealthServicesExerciseType(this.wireName);
final String wireName;
}
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
enum SetResultStatus { completed, skipped }
@ -439,6 +450,11 @@ final class Exercise {
? _businessTypesFromCategory(category)
: businessTypes;
List<HealthServicesExerciseType> get healthServicesExerciseTypeStrategy =>
healthServicesExerciseTypeStrategyForBusinessTypes(
effectiveBusinessTypes,
);
Exercise archive(DateTime now) {
return copyWith(archivedAt: now, metadata: metadata.touch(now));
}
@ -639,6 +655,9 @@ final class ProgramExercise {
this.exerciseImageMediaIdSnapshot,
List<String> exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
List<HealthServicesExerciseType>
healthServicesExerciseTypeStrategySnapshot =
const [],
List<ExerciseStep> exerciseStepsSnapshot = const [],
this.autoStartNextTimedStepSnapshot = true,
this.autoStartNextTimedStepOverride,
@ -668,6 +687,12 @@ final class ProgramExercise {
? [exerciseImageMediaIdSnapshot]
: exerciseImageMediaIdsSnapshot,
),
healthServicesExerciseTypeStrategySnapshot =
_validatedHealthServicesExerciseTypes(
healthServicesExerciseTypeStrategySnapshot.isEmpty
? legacyHealthServicesExerciseTypeStrategy
: healthServicesExerciseTypeStrategySnapshot,
),
exerciseStepsSnapshot = _validatedExerciseSteps(exerciseStepsSnapshot) {
_requireNonNegative(position, 'Position');
_requirePositive(setsCount, 'Sets count');
@ -712,6 +737,8 @@ final class ProgramExercise {
final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final List<HealthServicesExerciseType>
healthServicesExerciseTypeStrategySnapshot;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool autoStartNextTimedStepSnapshot;
final bool? autoStartNextTimedStepOverride;
@ -761,6 +788,8 @@ final class ProgramExercise {
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
healthServicesExerciseTypeStrategySnapshot:
exercise.healthServicesExerciseTypeStrategy,
exerciseStepsSnapshot: exercise.steps,
autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStep,
exerciseArchivedSnapshot: exercise.archivedAt != null,
@ -792,6 +821,10 @@ final class ProgramExercise {
'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot,
'healthServicesExerciseTypeStrategy':
healthServicesExerciseTypeStrategySnapshot
.map((type) => type.wireName)
.toList(),
'exerciseStepsSnapshot': exerciseStepsSnapshot
.map((step) => step.toSnapshotJson())
.toList(),
@ -2021,6 +2054,25 @@ List<BusinessExerciseType> _validatedBusinessExerciseTypes(
return List.unmodifiable(unique);
}
List<HealthServicesExerciseType> _validatedHealthServicesExerciseTypes(
List<HealthServicesExerciseType> types,
) {
final unique = <HealthServicesExerciseType>[];
for (final type in types) {
if (!unique.contains(type)) {
unique.add(type);
}
}
return List.unmodifiable(unique);
}
const legacyHealthServicesExerciseTypeStrategy = [
HealthServicesExerciseType.running,
HealthServicesExerciseType.walking,
HealthServicesExerciseType.highIntensityIntervalTraining,
HealthServicesExerciseType.workout,
];
List<BusinessExerciseType> _businessTypesFromCategory(
ExerciseCategory category,
) {
@ -2036,6 +2088,49 @@ List<BusinessExerciseType> _businessTypesFromCategory(
};
}
List<HealthServicesExerciseType>
healthServicesExerciseTypeStrategyForBusinessTypes(
List<BusinessExerciseType> types,
) {
if (types.isEmpty) {
return legacyHealthServicesExerciseTypeStrategy;
}
final output = <HealthServicesExerciseType>[];
void add(HealthServicesExerciseType type) {
if (!output.contains(type)) {
output.add(type);
}
}
for (final type in types) {
switch (type) {
case BusinessExerciseType.dribble:
case BusinessExerciseType.finishing:
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.highIntensityIntervalTraining);
add(HealthServicesExerciseType.workout);
case BusinessExerciseType.conditioning:
case BusinessExerciseType.highIntensity:
case BusinessExerciseType.defense:
add(HealthServicesExerciseType.highIntensityIntervalTraining);
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.workout);
case BusinessExerciseType.mobility:
case BusinessExerciseType.recovery:
add(HealthServicesExerciseType.walking);
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.workout);
case BusinessExerciseType.shoot:
case BusinessExerciseType.freeThrows:
add(HealthServicesExerciseType.highIntensityIntervalTraining);
add(HealthServicesExerciseType.running);
add(HealthServicesExerciseType.walking);
add(HealthServicesExerciseType.workout);
}
}
return List.unmodifiable(output);
}
void _requireAtLeastOneMeasure({
required bool hasTime,
required bool hasReps,

View File

@ -968,6 +968,16 @@ FROM pending_share_actions
"business_types_json TEXT NOT NULL DEFAULT '[]' "
'CHECK (json_valid(business_types_json))',
);
await _addColumnIfMissing(
tableName: 'program_exercises',
columnName: 'health_services_exercise_type_strategy_snapshot_json',
definition:
'health_services_exercise_type_strategy_snapshot_json TEXT NOT NULL '
"DEFAULT '[\"RUNNING\",\"WALKING\","
"\"HIGH_INTENSITY_INTERVAL_TRAINING\",\"WORKOUT\"]' "
'CHECK (json_valid('
'health_services_exercise_type_strategy_snapshot_json))',
);
}
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {

View File

@ -193,6 +193,10 @@ final class DriftStarterSeedRepository
await database
.into(database.programExercises)
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
await _writeProgramExerciseHealthServicesStrategySnapshot(
database,
exercise,
);
}
await database
.into(database.workoutTemplates)
@ -523,6 +527,10 @@ final class DriftLocalSyncChangeRepository
await database
.into(database.programExercises)
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
await _writeProgramExerciseHealthServicesStrategySnapshot(
database,
exercise,
);
}
});
return true;
@ -996,7 +1004,7 @@ final class DriftProgramRepository implements ProgramRepository {
.get();
return _programFromRow(
row,
exercises.map(_programExerciseFromRow).toList(),
await _programExercisesFromRows(database, exercises),
isExample: await _isProgramExample(id),
);
}
@ -1021,7 +1029,7 @@ final class DriftProgramRepository implements ProgramRepository {
programs.add(
_programFromRow(
row,
exercises.map(_programExerciseFromRow).toList(),
await _programExercisesFromRows(database, exercises),
isExample: await _isProgramExample(row.id),
),
);
@ -1055,9 +1063,15 @@ final class DriftProgramRepository implements ProgramRepository {
tableName: 'program_exercises',
entityType: 'ProgramExercise',
metadata: exercise.metadata,
write: () => database
write: () async {
await database
.into(database.programExercises)
.insertOnConflictUpdate(_programExerciseCompanion(exercise)),
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
await _writeProgramExerciseHealthServicesStrategySnapshot(
database,
exercise,
);
},
);
}
@ -3461,6 +3475,23 @@ Future<void> _writeExerciseStarterMetadata(
);
}
Future<void> _writeProgramExerciseHealthServicesStrategySnapshot(
db.AppDatabase database,
domain.ProgramExercise exercise,
) async {
await database.customStatement(
'UPDATE program_exercises SET '
'health_services_exercise_type_strategy_snapshot_json = ? '
'WHERE id = ?',
[
_encodeHealthServicesExerciseTypes(
exercise.healthServicesExerciseTypeStrategySnapshot,
),
exercise.metadata.id,
],
);
}
Future<void> _writeProgramStarterMetadata(
db.AppDatabase database,
domain.Program program,
@ -4083,7 +4114,21 @@ db.ProgramExercisesCompanion _programExerciseCompanion(
);
}
domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
Future<List<domain.ProgramExercise>> _programExercisesFromRows(
db.AppDatabase database,
List<db.ProgramExercise> rows,
) async {
final output = <domain.ProgramExercise>[];
for (final row in rows) {
output.add(await _programExerciseFromRow(database, row));
}
return output;
}
Future<domain.ProgramExercise> _programExerciseFromRow(
db.AppDatabase database,
db.ProgramExercise row,
) async {
return domain.ProgramExercise(
metadata: _metadataFromRow(row),
programId: row.programId,
@ -4102,6 +4147,8 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride,
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
healthServicesExerciseTypeStrategySnapshot:
await _programExerciseHealthServicesStrategySnapshot(database, row.id),
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
availableTimeSnapshot: row.availableTimeSnapshot,
availableRepsSnapshot: row.availableRepsSnapshot,
@ -4121,6 +4168,25 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
);
}
Future<List<domain.HealthServicesExerciseType>>
_programExerciseHealthServicesStrategySnapshot(
db.AppDatabase database,
String id,
) async {
final row = await database
.customSelect(
'SELECT health_services_exercise_type_strategy_snapshot_json '
'FROM program_exercises WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
return _decodeHealthServicesExerciseTypesJson(
row?.readNullable<String>(
'health_services_exercise_type_strategy_snapshot_json',
),
);
}
db.WorkoutTemplatesCompanion _workoutTemplateCompanion(
domain.WorkoutTemplate template,
) {
@ -5802,6 +5868,10 @@ List<domain.ProgramExercise> _programExercisesFromPayload(
),
exerciseVideoMediaIdSnapshot:
map['exerciseVideoMediaIdSnapshot'] as String?,
healthServicesExerciseTypeStrategySnapshot:
_healthServicesExerciseTypesFromPayload(
map['healthServicesExerciseTypeStrategy'],
),
exerciseStepsSnapshot: _stepsFromPayload(
map['exerciseStepsSnapshot'],
),
@ -6069,6 +6139,12 @@ String _encodeBusinessExerciseTypes(List<domain.BusinessExerciseType> types) {
return jsonEncode(types.map(_businessExerciseTypeToDb).toList());
}
String _encodeHealthServicesExerciseTypes(
List<domain.HealthServicesExerciseType> types,
) {
return jsonEncode(types.map((type) => type.wireName).toList());
}
List<domain.BusinessExerciseType> _decodeBusinessExerciseTypesJson(
String? encoded,
) {
@ -6083,6 +6159,20 @@ List<domain.BusinessExerciseType> _decodeBusinessExerciseTypesJson(
}
}
List<domain.HealthServicesExerciseType> _decodeHealthServicesExerciseTypesJson(
String? encoded,
) {
if (encoded == null || encoded.trim().isEmpty) {
return domain.legacyHealthServicesExerciseTypeStrategy;
}
try {
final decoded = jsonDecode(encoded);
return _healthServicesExerciseTypesFromPayload(decoded);
} on Object {
return domain.legacyHealthServicesExerciseTypeStrategy;
}
}
List<domain.BusinessExerciseType> _businessExerciseTypesFromPayload(
Object? value,
) {
@ -6099,6 +6189,24 @@ List<domain.BusinessExerciseType> _businessExerciseTypesFromPayload(
return List.unmodifiable(output);
}
List<domain.HealthServicesExerciseType> _healthServicesExerciseTypesFromPayload(
Object? value,
) {
if (value is! List) {
return domain.legacyHealthServicesExerciseTypeStrategy;
}
final output = <domain.HealthServicesExerciseType>[];
for (final raw in value.whereType<String>()) {
final type = _healthServicesExerciseTypeFromDbOrNull(raw);
if (type != null && !output.contains(type)) {
output.add(type);
}
}
return output.isEmpty
? domain.legacyHealthServicesExerciseTypeStrategy
: List.unmodifiable(output);
}
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
if (value is! List) {
return const [];
@ -6364,6 +6472,17 @@ domain.BusinessExerciseType? _businessExerciseTypeFromDbOrNull(String value) =>
_ => null,
};
domain.HealthServicesExerciseType? _healthServicesExerciseTypeFromDbOrNull(
String value,
) => switch (value) {
'RUNNING' => domain.HealthServicesExerciseType.running,
'WALKING' => domain.HealthServicesExerciseType.walking,
'HIGH_INTENSITY_INTERVAL_TRAINING' =>
domain.HealthServicesExerciseType.highIntensityIntervalTraining,
'WORKOUT' => domain.HealthServicesExerciseType.workout,
_ => null,
};
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
domain.SetResultStatus.completed => 'completed',
domain.SetResultStatus.skipped => 'skipped',

View File

@ -1,4 +1,4 @@
const int watchBridgeSchemaVersion = 6;
const int watchBridgeSchemaVersion = 7;
enum WatchCommandType {
startCurrentExercise,
@ -300,6 +300,7 @@ final class WatchSessionProjection {
this.manualScoreTargetLabel,
this.manualScoreRepsTargetValue,
this.manualScoreScope,
this.healthServicesExerciseTypeStrategy = const [],
});
factory WatchSessionProjection.fromJson(Map<String, Object?> json) {
@ -362,6 +363,9 @@ final class WatchSessionProjection {
json['manualScoreScope'],
WatchManualScoreScope.values,
),
healthServicesExerciseTypeStrategy: _stringListFromJson(
json['healthServicesExerciseTypeStrategy'],
),
);
}
@ -398,6 +402,7 @@ final class WatchSessionProjection {
final String? manualScoreTargetLabel;
final int? manualScoreRepsTargetValue;
final WatchManualScoreScope? manualScoreScope;
final List<String> healthServicesExerciseTypeStrategy;
Map<String, Object?> toJson() {
return {
@ -438,6 +443,7 @@ final class WatchSessionProjection {
'manualScoreTargetLabel': manualScoreTargetLabel,
'manualScoreRepsTargetValue': manualScoreRepsTargetValue,
'manualScoreScope': manualScoreScope?.name,
'healthServicesExerciseTypeStrategy': healthServicesExerciseTypeStrategy,
};
}
@ -477,7 +483,11 @@ final class WatchSessionProjection {
manualScoreTargetValue == other.manualScoreTargetValue &&
manualScoreTargetLabel == other.manualScoreTargetLabel &&
manualScoreRepsTargetValue == other.manualScoreRepsTargetValue &&
manualScoreScope == other.manualScoreScope;
manualScoreScope == other.manualScoreScope &&
_listEquals(
healthServicesExerciseTypeStrategy,
other.healthServicesExerciseTypeStrategy,
);
}
@override
@ -516,6 +526,7 @@ final class WatchSessionProjection {
manualScoreTargetLabel,
manualScoreRepsTargetValue,
manualScoreScope,
Object.hashAll(healthServicesExerciseTypeStrategy),
]);
}
}
@ -886,6 +897,13 @@ String? _nullableStringFromJson(Object? value) {
return value is String ? value : null;
}
List<String> _stringListFromJson(Object? value) {
if (value is! List) {
return const [];
}
return value.whereType<String>().toList(growable: false);
}
int _intFromJson(Object? value, int fallback) {
return value is int ? value : fallback;
}

View File

@ -227,6 +227,11 @@ void main() {
manualScoreTargetLabel: 'Cible',
manualScoreRepsTargetValue: 12,
manualScoreScope: WatchManualScoreScope.step,
healthServicesExerciseTypeStrategy: const [
'RUNNING',
'HIGH_INTENSITY_INTERVAL_TRAINING',
'WORKOUT',
],
);
final decoded = WatchSessionProjection.fromJson(
@ -274,6 +279,7 @@ void main() {
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreRepsTargetValue, isNull);
expect(projection.manualScoreScope, isNull);
expect(projection.healthServicesExerciseTypeStrategy, isEmpty);
});
test('falls back to neutral values for absent required fields', () {
@ -300,6 +306,7 @@ void main() {
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreRepsTargetValue, isNull);
expect(projection.manualScoreScope, isNull);
expect(projection.healthServicesExerciseTypeStrategy, isEmpty);
});
});

View File

@ -54,6 +54,29 @@ 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());
final projection = await projector.project(revision: 1);
expect(projection.healthServicesExerciseTypeStrategy, [
'RUNNING',
'HIGH_INTENSITY_INTERVAL_TRAINING',
'WORKOUT',
]);
},
);
test('projects running with step timer before stopwatch score', () async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
@ -587,6 +610,7 @@ ActiveWorkoutSession _session({
bool? autoStartNextTimedStepSnapshot = true,
List<ExerciseStep> steps = const [],
String? secondExerciseName,
List<String> healthServicesExerciseTypeStrategy = const [],
}) {
final exerciseSnapshot = {
'id': 'exercise-snapshot-1',
@ -603,6 +627,7 @@ ActiveWorkoutSession _session({
.map((step) => step.toSnapshotJson())
.toList(),
'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot,
'healthServicesExerciseTypeStrategy': healthServicesExerciseTypeStrategy,
};
final secondExerciseSnapshot = secondExerciseName == null
? null

View File

@ -125,6 +125,10 @@ void main() {
expect(await columnNames('exercises'), contains('tags_json'));
expect(await columnNames('exercises'), contains('business_types_json'));
expect(
await columnNames('program_exercises'),
contains('health_services_exercise_type_strategy_snapshot_json'),
);
expect(await columnNames('programs'), contains('tags_json'));
expect(await columnNames('workout_templates'), contains('tags_json'));
expect(
@ -175,6 +179,48 @@ void main() {
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
]);
expect(restoredExercise?.healthServicesExerciseTypeStrategy, [
HealthServicesExerciseType.running,
HealthServicesExerciseType.highIntensityIntervalTraining,
HealthServicesExerciseType.workout,
]);
final program = Program(
metadata: _metadata('program-business-types', now),
name: 'Programme types',
defaultRestSeconds: 30,
exercises: [
ProgramExercise.snapshotFromExercise(
metadata: _metadata('program-exercise-business-types', now),
programId: 'program-business-types',
exercise: restoredExercise!,
position: 0,
setsCount: 1,
enabledMeasures: const {WorkoutMeasure.time},
),
],
);
await programRepository.save(program);
final restoredProgram = await programRepository.findById(
program.metadata.id,
);
expect(
restoredProgram
?.exercises
.single
.healthServicesExerciseTypeStrategySnapshot,
[
HealthServicesExerciseType.running,
HealthServicesExerciseType.highIntensityIntervalTraining,
HealthServicesExerciseType.workout,
],
);
expect(
restoredProgram?.exercises.single
.toSnapshotJson()['healthServicesExerciseTypeStrategy'],
['RUNNING', 'HIGH_INTENSITY_INTERVAL_TRAINING', 'WORKOUT'],
);
final legacyExercise = Exercise(
metadata: _metadata('exercise-business-types-legacy', now),
@ -193,6 +239,12 @@ void main() {
expect(restoredLegacy?.effectiveBusinessTypes, [
BusinessExerciseType.shoot,
]);
expect(restoredLegacy?.healthServicesExerciseTypeStrategy, [
HealthServicesExerciseType.highIntensityIntervalTraining,
HealthServicesExerciseType.running,
HealthServicesExerciseType.walking,
HealthServicesExerciseType.workout,
]);
});
test(
@ -595,6 +647,23 @@ CREATE TABLE pending_share_actions (
],
);
await exerciseRepository.save(updatedExercise);
await programRepository.save(
Program(
metadata: _metadata('program-sync-business-types', now),
name: 'Program business types',
defaultRestSeconds: 45,
exercises: [
ProgramExercise.snapshotFromExercise(
metadata: _metadata('program-exercise-sync-business-types', now),
programId: 'program-sync-business-types',
exercise: updatedExercise,
position: 0,
setsCount: 1,
enabledMeasures: const {WorkoutMeasure.time},
),
],
),
);
final changes = await syncChangeRepository.listPendingChanges();
final payloadsById = {
@ -605,6 +674,13 @@ CREATE TABLE pending_share_actions (
'dribble',
'finishing',
]);
final programExercises =
payloadsById['program-sync-business-types']!['exercises'] as List;
expect(programExercises.single['healthServicesExerciseTypeStrategy'], [
'RUNNING',
'HIGH_INTENSITY_INTERVAL_TRAINING',
'WORKOUT',
]);
});
test('local sync payload includes full workout history aggregate', () async {

View File

@ -597,6 +597,8 @@ object WatchBridgePlugin {
"setIndex" to projection["setIndex"],
"passageIndex" to zeroBasedNullableIndex(projection["passageIndex"]),
"stepIndex" to zeroBasedNullableIndex(projection["stepIndex"]),
"healthServicesExerciseTypeStrategy" to
projection["healthServicesExerciseTypeStrategy"],
)
}

View File

@ -312,7 +312,13 @@ internal class WatchHeartRateCollector(
{
try {
val capabilities = capabilitiesFuture.get()
val config = exerciseConfigFromCapabilities(capabilities)
val requestedTypeNames = (
executionContext["healthServicesExerciseTypeStrategy"] as? List<*>
)?.filterIsInstance<String>().orEmpty()
val config = exerciseConfigFromCapabilities(
capabilities,
requestedTypeNames,
)
if (config == null) {
exerciseMetricsStartInFlight = false
Log.w(TAG, "no exercise type supports heart rate or distance sessionId=$sessionId")
@ -360,13 +366,11 @@ internal class WatchHeartRateCollector(
private fun exerciseConfigFromCapabilities(
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
requestedTypeNames: List<String>,
): ExerciseConfig? {
val requestedTypes = listOf(
ExerciseType.RUNNING,
ExerciseType.WALKING,
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT,
)
val requestedTypes = exerciseTypesFromNames(requestedTypeNames)
var distanceOnlyConfig: ExerciseConfig? = null
var caloriesOnlyConfig: ExerciseConfig? = null
var heartRateOnlyConfig: ExerciseConfig? = null
for (exerciseType in requestedTypes) {
if (exerciseType !in capabilities.supportedExerciseTypes) {
@ -376,15 +380,29 @@ internal class WatchHeartRateCollector(
.supportedDataTypes
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
if (DataType.DISTANCE !in supported) {
if (DataType.HEART_RATE_BPM !in supported) {
if (DataType.CALORIES in supported) {
dataTypes.add(DataType.CALORIES)
}
if (DataType.HEART_RATE_BPM in supported) {
dataTypes.add(DataType.HEART_RATE_BPM)
}
if (dataTypes.isEmpty()) {
Log.w(
TAG,
"exercise type lacks distance and heart rate type=$exerciseType supported=$supported",
"exercise type lacks usable metrics type=$exerciseType supported=$supported",
)
continue
}
dataTypes.add(DataType.HEART_RATE_BPM)
if (heartRateOnlyConfig == null) {
if (
DataType.CALORIES in dataTypes &&
caloriesOnlyConfig == null
) {
caloriesOnlyConfig = ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(false)
.build()
} else if (heartRateOnlyConfig == null) {
heartRateOnlyConfig = ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
@ -400,13 +418,44 @@ internal class WatchHeartRateCollector(
if (DataType.HEART_RATE_BPM in supported) {
dataTypes.add(DataType.HEART_RATE_BPM)
}
return ExerciseConfig.builder(exerciseType)
val config = ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(true)
.build()
if (DataType.CALORIES in dataTypes) {
return config
}
return heartRateOnlyConfig
if (distanceOnlyConfig == null) {
distanceOnlyConfig = config
}
}
return distanceOnlyConfig ?: caloriesOnlyConfig ?: heartRateOnlyConfig
}
private fun exerciseTypesFromNames(names: List<String>): List<ExerciseType> {
val mapped = names.mapNotNull { name ->
when (name) {
"RUNNING" -> ExerciseType.RUNNING
"WALKING" -> ExerciseType.WALKING
"HIGH_INTENSITY_INTERVAL_TRAINING" ->
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING
"WORKOUT" -> ExerciseType.WORKOUT
else -> null
}
}.toMutableList()
val fallback = listOf(
ExerciseType.RUNNING,
ExerciseType.WALKING,
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT,
)
for (exerciseType in fallback) {
if (exerciseType !in mapped) {
mapped.add(exerciseType)
}
}
return mapped.distinct()
}
private fun stopExerciseMetrics(context: Context) {