diff --git a/watch_app/android/app/build.gradle.kts b/watch_app/android/app/build.gradle.kts index b57f29d..3e928b6 100644 --- a/watch_app/android/app/build.gradle.kts +++ b/watch_app/android/app/build.gradle.kts @@ -38,7 +38,9 @@ android { } defaultConfig { - applicationId = "com.gametime.watch" + // Wear OS Data Layer requires matching package names and signatures + // across phone and watch APKs. + applicationId = "com.gametime.app" minSdk = 30 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 54e4599..466df41 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -5,8 +5,14 @@ - - + + + + + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + } } - private fun requestBodySensorsPermissionOnce() { - val activity = activity ?: return - if (bodySensorPermissionRequested) { + private fun requestSensorPermissionsOnce() { + val activity = activity ?: run { + Log.d(TAG, "sensor permission request pending: activity unavailable") return } - bodySensorPermissionRequested = true - activity.requestPermissions( - arrayOf(android.Manifest.permission.BODY_SENSORS), - BODY_SENSORS_PERMISSION_REQUEST, + if (sensorPermissionRequested) { + Log.d(TAG, "sensor permission request already attempted") + return + } + val permissions = requiredSensorPermissions() + .filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } + .toTypedArray() + if (permissions.isEmpty()) { + pendingSensorPermissionRequest = false + appContext?.let { heartRateCollector.onBodySensorsGranted(it) } + return + } + sensorPermissionRequested = true + Log.d(TAG, "request sensor permissions=${permissions.joinToString()}") + activity.requestPermissions(permissions, SENSOR_PERMISSION_REQUEST) + } + + private fun requestPendingSensorPermissionIfPossible() { + val context = appContext ?: return + if (!pendingSensorPermissionRequest || hasRequiredSensorPermissions(context)) { + return + } + requestSensorPermissionsOnce() + } + + private fun requiredSensorPermissions(): List { + val heartRatePermission = if (Build.VERSION.SDK_INT >= 36) { + READ_HEART_RATE_PERMISSION + } else { + android.Manifest.permission.BODY_SENSORS + } + return listOf( + heartRatePermission, + android.Manifest.permission.ACTIVITY_RECOGNITION, ) } 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 c5e522d..a6cc1ef 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 @@ -1,7 +1,9 @@ package com.gametime.watch.bridge import android.content.Context +import android.util.Log import androidx.health.services.client.HealthServices +import androidx.health.services.client.MeasureClient import androidx.health.services.client.MeasureCallback import androidx.health.services.client.data.Availability import androidx.health.services.client.data.DataPointContainer @@ -19,14 +21,20 @@ internal class WatchHeartRateCollector( private val sensorSamplePath: String, private val onLocalSample: (Map) -> Unit = {}, ) { + private companion object { + const val TAG = "GTWatchHeartRate" + } + private var sessionId: String? = null private var sampleCount = 0 private var sampleSum = 0.0 private var minBpm: Int? = null private var maxBpm: Int? = null + private var distanceMeters: Double? = null + private var caloriesKcal: Double? = null private var sampleSequence = 0 private var executionContext: Map = emptyMap() - private var registered = false + private val registeredDataTypes = mutableSetOf>() private var shouldAggregate = false private var appContext: Context? = null @@ -34,19 +42,39 @@ internal class WatchHeartRateCollector( override fun onAvailabilityChanged( dataType: DeltaDataType<*, *>, availability: Availability, - ) = Unit + ) { + Log.d(TAG, "availability dataType=$dataType availability=$availability") + } override fun onDataReceived(data: DataPointContainer) { if (!shouldAggregate) { return } + var latestHeartRateBpm: Int? = null for (point in data.getData(DataType.HEART_RATE_BPM)) { - record(point.value) + latestHeartRateBpm = recordHeartRate(point.value) + } + var updatedDistance = false + for (point in data.getData(DataType.DISTANCE)) { + if (point.value > 0) { + distanceMeters = (distanceMeters ?: 0.0) + point.value + updatedDistance = true + } + } + var updatedCalories = false + for (point in data.getData(DataType.CALORIES)) { + if (point.value > 0) { + caloriesKcal = (caloriesKcal ?: 0.0) + point.value + updatedCalories = true + } + } + if (latestHeartRateBpm != null || updatedDistance || updatedCalories) { + sendSample(latestHeartRateBpm) } } override fun onRegistrationFailed(throwable: Throwable) { - registered = false + Log.w(TAG, "measure callback registration failed", throwable) } } @@ -69,18 +97,14 @@ internal class WatchHeartRateCollector( } fun start(context: Context) { - if (registered || sessionId.isNullOrBlank()) { + if (sessionId.isNullOrBlank()) { return } appContext = context.applicationContext - try { - HealthServices.getClient(context) - .measureClient - .registerMeasureCallback(DataType.HEART_RATE_BPM, callback) - registered = true - } catch (_: RuntimeException) { - registered = false - } + val measureClient = HealthServices.getClient(context).measureClient + registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) + registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) + registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES) } fun pause(context: Context) { @@ -93,23 +117,28 @@ internal class WatchHeartRateCollector( val completedSessionId = sessionId if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) { sendSummary(context, completedSessionId) + } else { + Log.d( + TAG, + "skip summary sessionId=$completedSessionId sampleCount=$sampleCount", + ) } reset(null) } - private fun record(bpm: Double) { + private fun recordHeartRate(bpm: Double): Int? { if (bpm <= 0) { - return + return null } sampleCount += 1 sampleSum += bpm val rounded = bpm.roundToInt() minBpm = minOf(minBpm ?: rounded, rounded) maxBpm = maxOf(maxBpm ?: rounded, rounded) - sendSample(rounded) + return rounded } - private fun sendSample(bpm: Int) { + private fun sendSample(bpm: Int?) { val activeSessionId = sessionId ?: return val context = appContext ?: return sampleSequence += 1 @@ -126,17 +155,26 @@ internal class WatchHeartRateCollector( "passageIndex" to executionContext["passageIndex"], "stepIndex" to executionContext["stepIndex"], "heartRateBpm" to bpm, + "distanceMeters" to distanceMeters, + "caloriesKcal" to caloriesKcal, ) onLocalSample(sample) val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8) Wearable.getCapabilityClient(context) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .addOnSuccessListener { capability -> + Log.d( + TAG, + "send sample sessionId=$activeSessionId bpm=$bpm distance=$distanceMeters calories=$caloriesKcal nodes=${capability.nodes.size}", + ) for (node in capability.nodes) { Wearable.getMessageClient(context) .sendMessage(node.id, sensorSamplePath, payload) } } + .addOnFailureListener { error -> + Log.w(TAG, "sample capability lookup failed", error) + } } private fun sendSummary(context: Context, completedSessionId: String) { @@ -155,30 +193,56 @@ internal class WatchHeartRateCollector( Wearable.getCapabilityClient(context) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .addOnSuccessListener { capability -> + Log.d( + TAG, + "send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}", + ) for (node in capability.nodes) { Wearable.getMessageClient(context) .sendMessage(node.id, sensorSummaryPath, payload) } } + .addOnFailureListener { error -> + Log.w(TAG, "summary capability lookup failed", error) + } } private fun unregister(context: Context) { - if (!registered) { + if (registeredDataTypes.isEmpty()) { return } - HealthServices.getClient(context) - .measureClient - .unregisterMeasureCallbackAsync(DataType.HEART_RATE_BPM, callback) - registered = false + val measureClient = HealthServices.getClient(context).measureClient + for (dataType in registeredDataTypes.toList()) { + measureClient.unregisterMeasureCallbackAsync(dataType, callback) + } + registeredDataTypes.clear() appContext = null } + private fun registerMeasureCallbackIfNeeded( + measureClient: MeasureClient, + dataType: DeltaDataType<*, *>, + ) { + if (registeredDataTypes.contains(dataType)) { + return + } + try { + measureClient.registerMeasureCallback(dataType, callback) + registeredDataTypes.add(dataType) + Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId") + } catch (error: RuntimeException) { + Log.w(TAG, "measure callback registration threw dataType=$dataType", error) + } + } + private fun reset(nextSessionId: String?) { sessionId = nextSessionId sampleCount = 0 sampleSum = 0.0 minBpm = null maxBpm = null + distanceMeters = null + caloriesKcal = null sampleSequence = 0 executionContext = emptyMap() shouldAggregate = false diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index ce7faa4..ae0621b 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -620,6 +620,33 @@ final class _StatusLine extends StatelessWidget { } } +final class _MainHeartRateLine extends StatelessWidget { + const _MainHeartRateLine({required this.sample}); + + final WatchSensorSample? sample; + + @override + Widget build(BuildContext context) { + final label = _heartRateLabel(sample); + if (label == null) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + 'FC $label', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFFA7ADBA), + fontSize: 11, + ), + ), + ); + } +} + final class _ScaledContent extends StatelessWidget { const _ScaledContent({required this.child}); @@ -679,6 +706,7 @@ final class _ActiveContent extends StatelessWidget { _SmallLabel(dominantLabel), const SizedBox(height: 2), _DominantValue(dominantValue), + _MainHeartRateLine(sample: state.sensorSample), if (timer != null) ...[ const SizedBox(height: 3), _TimerToggleButton( @@ -792,6 +820,7 @@ final class _ManualScoreContent extends StatelessWidget { ? const _PendingDot() : const SizedBox.shrink(), ), + _MainHeartRateLine(sample: state.sensorSample), const SizedBox(height: 5), if (timer != null) _CompactTimerLine( @@ -950,6 +979,7 @@ final class _RestContent extends StatelessWidget { const _SmallLabel('REPOS'), const SizedBox(height: 2), _DominantValue(timer == null ? '--:--' : _timerText(timer)), + _MainHeartRateLine(sample: state.sensorSample), if (timer != null) ...[ const SizedBox(height: 3), _TimerToggleButton( diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index fd250fb..e5b26d9 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -212,62 +212,70 @@ void main() { viewModel.dispose(); }); - testWidgets('shows telemetry only on stats page when available', ( - tester, - ) async { - final client = _FakeNativeWatchBridgeClient(); - final viewModel = WatchSessionViewModel(nativeClient: client); + testWidgets( + 'shows live heart rate on main page and full telemetry on stats', + (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); + 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), - ), - ); + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); - client.emitProjection(_runningProjection()); - await tester.pump(); - expect(find.byTooltip('Stats'), findsNothing); + client.emitProjection(_runningProjection()); + await tester.pump(); + expect(find.byTooltip('Stats'), findsNothing); - client.emitSensorSample( - WatchSensorSample( - sessionId: 'session-1', - capturedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, - heartRateBpm: 142, - distanceMeters: 840, - caloriesKcal: 186, - ), - ); - await tester.pump(); + client.emitSensorSample( + WatchSensorSample( + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 27, + 10, + ).millisecondsSinceEpoch, + heartRateBpm: 142, + distanceMeters: 840, + caloriesKcal: 186, + ), + ); + await tester.pump(); - expect(find.text('142 bpm'), findsNothing); - expect(find.byTooltip('Stats'), findsOneWidget); + expect(find.text('FC 142 bpm'), findsOneWidget); + expect(find.text('840 m'), findsNothing); + expect(find.text('186 kcal'), findsNothing); + 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(); + 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('FC'), findsOneWidget); - expect(find.text('142 bpm'), findsOneWidget); - expect(find.text('840 m'), findsOneWidget); - expect(find.text('186 kcal'), findsOneWidget); + expect(find.text('Stats'), findsOneWidget); + expect(find.text('FC'), findsOneWidget); + expect(find.text('142 bpm'), findsOneWidget); + expect(find.text('840 m'), findsOneWidget); + expect(find.text('186 kcal'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - viewModel.dispose(); - }); + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }, + ); testWidgets( 'sends pause command from the icon button when a timer dominates',