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',