From 2ae716206ad8c1eaad4c51b860d9249e1ee70736 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 30 Jul 2026 16:17:31 +0200 Subject: [PATCH] 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 --- lib/application/use_cases.dart | 23 ++- lib/domain/entities.dart | 95 +++++++++++++ lib/infrastructure/local/app_database.dart | 10 ++ .../local/drift_repositories.dart | 131 +++++++++++++++++- .../lib/src/watch_bridge_contract.dart | 22 ++- .../test/watch_bridge_contract_test.dart | 7 + .../watch_companion_projection_test.dart | 25 ++++ .../drift_repositories_test.dart | 76 ++++++++++ .../watch/bridge/WatchBridgePlugin.kt | 2 + .../watch/bridge/WatchHeartRateCollector.kt | 75 ++++++++-- 10 files changed, 444 insertions(+), 22 deletions(-) diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index e95e7e4..81dd29c 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -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 _stringListFromSnapshot(Object? value) { + if (value is! List) { + return const []; + } + return value.whereType().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 _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 healthServicesExerciseTypeStrategy; final List steps; final bool autoStartNextTimedStepEffective; } diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index b3572e4..f942364 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -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 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 exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, + List + healthServicesExerciseTypeStrategySnapshot = + const [], List 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 exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; + final List + healthServicesExerciseTypeStrategySnapshot; final List 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 _validatedBusinessExerciseTypes( return List.unmodifiable(unique); } +List _validatedHealthServicesExerciseTypes( + List types, +) { + final unique = []; + 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 _businessTypesFromCategory( ExerciseCategory category, ) { @@ -2036,6 +2088,49 @@ List _businessTypesFromCategory( }; } +List +healthServicesExerciseTypeStrategyForBusinessTypes( + List types, +) { + if (types.isEmpty) { + return legacyHealthServicesExerciseTypeStrategy; + } + final output = []; + 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, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index deae302..1d7a52c 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -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 _backfillWorkoutHistorySetSourceExerciseIds() async { diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 55b0e09..217998d 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -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 - .into(database.programExercises) - .insertOnConflictUpdate(_programExerciseCompanion(exercise)), + write: () async { + await database + .into(database.programExercises) + .insertOnConflictUpdate(_programExerciseCompanion(exercise)); + await _writeProgramExerciseHealthServicesStrategySnapshot( + database, + exercise, + ); + }, ); } @@ -3461,6 +3475,23 @@ Future _writeExerciseStarterMetadata( ); } +Future _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 _writeProgramStarterMetadata( db.AppDatabase database, domain.Program program, @@ -4083,7 +4114,21 @@ db.ProgramExercisesCompanion _programExerciseCompanion( ); } -domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) { +Future> _programExercisesFromRows( + db.AppDatabase database, + List rows, +) async { + final output = []; + for (final row in rows) { + output.add(await _programExerciseFromRow(database, row)); + } + return output; +} + +Future _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> +_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(id)], + ) + .getSingleOrNull(); + return _decodeHealthServicesExerciseTypesJson( + row?.readNullable( + 'health_services_exercise_type_strategy_snapshot_json', + ), + ); +} + db.WorkoutTemplatesCompanion _workoutTemplateCompanion( domain.WorkoutTemplate template, ) { @@ -5802,6 +5868,10 @@ List _programExercisesFromPayload( ), exerciseVideoMediaIdSnapshot: map['exerciseVideoMediaIdSnapshot'] as String?, + healthServicesExerciseTypeStrategySnapshot: + _healthServicesExerciseTypesFromPayload( + map['healthServicesExerciseTypeStrategy'], + ), exerciseStepsSnapshot: _stepsFromPayload( map['exerciseStepsSnapshot'], ), @@ -6069,6 +6139,12 @@ String _encodeBusinessExerciseTypes(List types) { return jsonEncode(types.map(_businessExerciseTypeToDb).toList()); } +String _encodeHealthServicesExerciseTypes( + List types, +) { + return jsonEncode(types.map((type) => type.wireName).toList()); +} + List _decodeBusinessExerciseTypesJson( String? encoded, ) { @@ -6083,6 +6159,20 @@ List _decodeBusinessExerciseTypesJson( } } +List _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 _businessExerciseTypesFromPayload( Object? value, ) { @@ -6099,6 +6189,24 @@ List _businessExerciseTypesFromPayload( return List.unmodifiable(output); } +List _healthServicesExerciseTypesFromPayload( + Object? value, +) { + if (value is! List) { + return domain.legacyHealthServicesExerciseTypeStrategy; + } + final output = []; + for (final raw in value.whereType()) { + final type = _healthServicesExerciseTypeFromDbOrNull(raw); + if (type != null && !output.contains(type)) { + output.add(type); + } + } + return output.isEmpty + ? domain.legacyHealthServicesExerciseTypeStrategy + : List.unmodifiable(output); +} + List _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', diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart index c049d84..9d62f3b 100644 --- a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -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 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 healthServicesExerciseTypeStrategy; Map 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 _stringListFromJson(Object? value) { + if (value is! List) { + return const []; + } + return value.whereType().toList(growable: false); +} + int _intFromJson(Object? value, int fallback) { return value is int ? value : fallback; } diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart index e6245f3..2e7751e 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -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); }); }); diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index 954c88d..f16a3a2 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -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 steps = const [], String? secondExerciseName, + List 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 diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 597f620..775321b 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -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 { diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index 7145454..a15e8f5 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -597,6 +597,8 @@ object WatchBridgePlugin { "setIndex" to projection["setIndex"], "passageIndex" to zeroBasedNullableIndex(projection["passageIndex"]), "stepIndex" to zeroBasedNullableIndex(projection["stepIndex"]), + "healthServicesExerciseTypeStrategy" to + projection["healthServicesExerciseTypeStrategy"], ) } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index 26ed4d6..728fa56 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -312,7 +312,13 @@ internal class WatchHeartRateCollector( { try { val capabilities = capabilitiesFuture.get() - val config = exerciseConfigFromCapabilities(capabilities) + val requestedTypeNames = ( + executionContext["healthServicesExerciseTypeStrategy"] as? List<*> + )?.filterIsInstance().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, ): 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>() 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 + } + if (distanceOnlyConfig == null) { + distanceOnlyConfig = config + } } - return heartRateOnlyConfig + return distanceOnlyConfig ?: caloriesOnlyConfig ?: heartRateOnlyConfig + } + + private fun exerciseTypesFromNames(names: List): List { + 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) {