From b4503d6b5f11caafa7bbf5b7b1640192adc2bee8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 30 Jul 2026 14:55:52 +0200 Subject: [PATCH] feat(watch): renforce la vibration de fin de chrono et consolide la remontee des donnees stats montre Cote montre pour #180 (vibration timerFinished plus longue et plus forte) et #179 (contrat/collecte des donnees stats vers le telephone). Co-Authored-By: Claude Opus 4.8 --- .../lib/src/watch_bridge_contract.dart | 12 ++- .../test/watch_bridge_contract_test.dart | 1 + .../wear_data_layer_adapter_test.dart | 15 ++++ .../watch/bridge/WatchBridgePlugin.kt | 32 +++++++- .../watch/bridge/WatchHeartRateCollector.kt | 2 + .../application/watch_session_view_model.dart | 6 +- .../presentation/watch_session_screen.dart | 75 +++++++++++++------ .../watch_session_screen_test.dart | 56 ++++++++++++++ 8 files changed, 170 insertions(+), 29 deletions(-) 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 927250c..c049d84 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 = 5; +const int watchBridgeSchemaVersion = 6; enum WatchCommandType { startCurrentExercise, @@ -617,6 +617,7 @@ final class WatchSensorSample { this.heartRateBpm, this.distanceMeters, this.caloriesKcal, + this.sensorCollectionStatus, }) : capturedAtEpochMs = capturedAtEpochMs ?? recordedAtEpochMs ?? 0; factory WatchSensorSample.fromJson(Map json) { @@ -642,6 +643,9 @@ final class WatchSensorSample { heartRateBpm: _nullableIntFromJson(json['heartRateBpm']), distanceMeters: _nullableDoubleFromJson(json['distanceMeters']), caloriesKcal: _nullableDoubleFromJson(json['caloriesKcal']), + sensorCollectionStatus: _nullableStringFromJson( + json['sensorCollectionStatus'], + ), ); } @@ -660,6 +664,7 @@ final class WatchSensorSample { final int? heartRateBpm; final double? distanceMeters; final double? caloriesKcal; + final String? sensorCollectionStatus; int get recordedAtEpochMs => capturedAtEpochMs; @@ -681,6 +686,7 @@ final class WatchSensorSample { 'heartRateBpm': heartRateBpm, 'distanceMeters': distanceMeters, 'caloriesKcal': caloriesKcal, + 'sensorCollectionStatus': sensorCollectionStatus, }; } @@ -702,7 +708,8 @@ final class WatchSensorSample { stepSnapshotId == other.stepSnapshotId && heartRateBpm == other.heartRateBpm && distanceMeters == other.distanceMeters && - caloriesKcal == other.caloriesKcal; + caloriesKcal == other.caloriesKcal && + sensorCollectionStatus == other.sensorCollectionStatus; } @override @@ -723,6 +730,7 @@ final class WatchSensorSample { heartRateBpm, distanceMeters, caloriesKcal, + sensorCollectionStatus, ); } } 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 a35cfdf..e6245f3 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -151,6 +151,7 @@ void main() { heartRateBpm: 142, distanceMeters: 840.5, caloriesKcal: 184.2, + sensorCollectionStatus: 'active', ); final decoded = WatchSensorSample.fromJson( diff --git a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart index 14d6f16..219badc 100644 --- a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart +++ b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart @@ -672,6 +672,21 @@ final class _FakeWorkoutTelemetryRepository .toList(growable: false); } + @override + Future> listSamplesForScope({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + return samples + .where((sample) => sample.sessionId == sessionId) + .toList(growable: false); + } + @override Future replaceAggregatesForSession({ required String sessionId, 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 c321794..7145454 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 @@ -170,8 +170,7 @@ object WatchBridgePlugin { return } sensorPermissionRequestInFlight = false - val granted = appContext?.let(::hasRequiredSensorPermissions) == true || - grantResults.all { it == PackageManager.PERMISSION_GRANTED } + val granted = appContext?.let(::hasRequiredRuntimePermissions) == true if (granted) { pendingSensorPermissionRequest = false Log.d(TAG, "sensor permissions granted") @@ -187,6 +186,7 @@ object WatchBridgePlugin { grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED } Log.w(TAG, "sensor permissions denied=${denied.joinToString()}") + emitSensorPermissionRequired() } } @@ -458,12 +458,12 @@ object WatchBridgePlugin { @Suppress("DEPRECATION") context.getSystemService(Vibrator::class.java) } ?: return - val timings = longArrayOf(0L, 180L, 120L, 260L) + val timings = longArrayOf(0L, 320L, 90L, 420L, 90L, 520L) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator.vibrate( VibrationEffect.createWaveform( timings, - intArrayOf(0, 255, 0, 255), + intArrayOf(0, 255, 0, 255, 0, 255), -1, ), ) @@ -492,6 +492,7 @@ object WatchBridgePlugin { executionContext = telemetryContext(projection), ) pendingSensorPermissionRequest = true + emitSensorPermissionRequired() requestSensorPermissionsOnce() return } @@ -603,6 +604,29 @@ object WatchBridgePlugin { val index = (value as? Number)?.toInt() ?: return null return if (index > 0) index - 1 else null } + + private fun emitSensorPermissionRequired() { + val projection = lastSensorProjection ?: lastActiveProjection ?: return + val sessionId = projection["deviceSessionId"] as? String ?: return + if (sessionId.isBlank()) { + return + } + emitSensorSample( + mapOf( + "schemaVersion" to 4, + "sampleId" to "$sessionId-permission-${System.currentTimeMillis()}", + "sessionId" to sessionId, + "capturedAtEpochMs" to System.currentTimeMillis(), + "recordedAtEpochMs" to System.currentTimeMillis(), + "programIndex" to projection["programIndex"], + "exerciseIndex" to projection["exerciseIndex"], + "setIndex" to projection["setIndex"], + "passageIndex" to zeroBasedNullableIndex(projection["passageIndex"]), + "stepIndex" to zeroBasedNullableIndex(projection["stepIndex"]), + "sensorCollectionStatus" to "authorizationRequired", + ), + ) + } } private fun JSONObject.toMap(): Map { 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 9cae76a..26ed4d6 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 @@ -445,6 +445,8 @@ internal class WatchHeartRateCollector( sampleSequence = 0 executionContext = emptyMap() shouldAggregate = false + exerciseMetricsStarted = false + exerciseMetricsStartInFlight = false exerciseHeartRateSupported = false exerciseHeartRateObserved = false } diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index 649ae88..d408ae5 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -86,7 +86,8 @@ final class WatchSessionUiState { return sample != null && (sample.heartRateBpm != null || sample.distanceMeters != null || - sample.caloriesKcal != null); + sample.caloriesKcal != null || + sample.sensorCollectionStatus != null); } } @@ -348,7 +349,8 @@ final class WatchSessionViewModel extends ValueNotifier { final hasMetric = sample.heartRateBpm != null || sample.distanceMeters != null || - sample.caloriesKcal != null; + sample.caloriesKcal != null || + sample.sensorCollectionStatus != null; if (!hasMetric) { return; } diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index d650505..016f787 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -1296,20 +1296,24 @@ final class _StatsView extends StatelessWidget { Widget build(BuildContext context) { final sample = state.sensorSample; final metrics = [ - if (_heartRateLabel(sample) case final value?) - _StatsMetric(icon: Icons.favorite, label: 'FC', value: value), - if (_distanceLabel(sample) case final value?) - _StatsMetric( - icon: Icons.directions_run, - label: 'Distance', - value: value, - ), - if (_caloriesLabel(sample) case final value?) - _StatsMetric( - icon: Icons.local_fire_department, - label: 'Calories', - value: value, - ), + _StatsMetric( + icon: Icons.favorite, + label: 'FC', + value: _heartRateMetricLabel(sample), + available: _heartRateLabel(sample) != null, + ), + _StatsMetric( + icon: Icons.directions_run, + label: 'Distance', + value: _distanceMetricLabel(sample), + available: _distanceLabel(sample) != null, + ), + _StatsMetric( + icon: Icons.local_fire_department, + label: 'Calories', + value: _caloriesMetricLabel(sample), + available: _caloriesLabel(sample) != null, + ), ]; return Stack( children: [ @@ -1356,11 +1360,13 @@ final class _StatsMetric extends StatelessWidget { required this.icon, required this.label, required this.value, + required this.available, }); final IconData icon; final String label; final String value; + final bool available; @override Widget build(BuildContext context) { @@ -1384,13 +1390,18 @@ final class _StatsMetric extends StatelessWidget { style: Theme.of(context).textTheme.bodySmall, ), ), - Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontSize: 14, - color: const Color(0xFFC9A24A), + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: available ? 14 : 11, + color: available + ? const Color(0xFFC9A24A) + : const Color(0xFFA7ADBA), + ), ), ), ], @@ -1777,6 +1788,28 @@ String? _heartRateLabel(WatchSensorSample? sample) { return '$bpm bpm'; } +String _heartRateMetricLabel(WatchSensorSample? sample) { + return _heartRateLabel(sample) ?? _sensorUnavailableLabel(sample); +} + +String _distanceMetricLabel(WatchSensorSample? sample) { + return _distanceLabel(sample) ?? _sensorUnavailableLabel(sample); +} + +String _caloriesMetricLabel(WatchSensorSample? sample) { + return _caloriesLabel(sample) ?? _sensorUnavailableLabel(sample); +} + +String _sensorUnavailableLabel(WatchSensorSample? sample) { + if (sample?.sensorCollectionStatus == 'authorizationRequired') { + return 'Autorisation requise'; + } + if (sample == null) { + return 'Mesure en attente'; + } + return 'Donnée indisponible'; +} + String? _distanceLabel(WatchSensorSample? sample) { final meters = sample?.distanceMeters; if (meters == null || meters < 0) { diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 95d12c9..9635774 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -347,6 +347,62 @@ void main() { }, ); + testWidgets( + 'shows explicit metric states when sensor permission is required', + (tester) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel(nativeClient: client); + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(192, 192); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); + + client.emitProjection(_runningProjection()); + await tester.pump(); + client.emitSensorSample( + WatchSensorSample( + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 30, + 10, + ).millisecondsSinceEpoch, + sensorCollectionStatus: 'authorizationRequired', + ), + ); + await tester.pump(); + + expect(find.byTooltip('Stats'), findsOneWidget); + + await tester.drag( + find.byKey(const ValueKey('watch-session-page')), + const Offset(-220, 0), + ); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const ValueKey('watch-actions-page')), + const Offset(-220, 0), + ); + await tester.pumpAndSettle(); + + expect(find.text('Stats'), findsOneWidget); + expect(find.text('Autorisation requise'), findsNWidgets(3)); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }, + ); + testWidgets( 'sends pause command from the icon button when a timer dominates', (tester) async {