diff --git a/.ideai/skills/scripts/build-watch-apk.sh b/.ideai/skills/scripts/build-watch-apk.sh
new file mode 100755
index 0000000..63d6392
--- /dev/null
+++ b/.ideai/skills/scripts/build-watch-apk.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+MODE="${1:-debug}"
+
+if [[ "$MODE" != "debug" && "$MODE" != "release" ]]; then
+ echo "Usage: $0 [debug|release]" >&2
+ exit 2
+fi
+
+PROJECT_ROOT="/home/anthony/Documents/Projects/GameTime"
+WATCH_ROOT="$PROJECT_ROOT/watch_app"
+BUILD_ENV_ROOT="$PROJECT_ROOT/.ideai/build-env"
+SDK_COPY="$BUILD_ENV_ROOT/flutter-sdk"
+HOME_DIR="$BUILD_ENV_ROOT/home"
+GRADLE_DIR="$BUILD_ENV_ROOT/gradle"
+PUB_CACHE_DIR="$BUILD_ENV_ROOT/pub-cache"
+TMP_DIR="$BUILD_ENV_ROOT/tmp"
+ANDROID_HOME="/opt/android-sdk"
+JAVA_HOME="/usr/lib/jvm/java-21-openjdk"
+FLUTTER_BIN="$SDK_COPY/bin/flutter"
+
+mkdir -p "$BUILD_ENV_ROOT" "$HOME_DIR/.config" "$HOME_DIR/.local/share" "$HOME_DIR/.cache" "$GRADLE_DIR" "$PUB_CACHE_DIR" "$TMP_DIR"
+
+if [[ ! -x "$FLUTTER_BIN" ]]; then
+ cp -a /opt/flutter "$SDK_COPY"
+fi
+
+export HOME="$HOME_DIR"
+export XDG_CONFIG_HOME="$HOME_DIR/.config"
+export XDG_DATA_HOME="$HOME_DIR/.local/share"
+export XDG_CACHE_HOME="$HOME_DIR/.cache"
+export GRADLE_USER_HOME="$GRADLE_DIR"
+export PUB_CACHE="$PUB_CACHE_DIR"
+export TMPDIR="$TMP_DIR"
+export TMP="$TMP_DIR"
+export TEMP="$TMP_DIR"
+export JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=$TMP_DIR"
+export GRADLE_OPTS="-Djava.io.tmpdir=$TMP_DIR"
+export ANDROID_HOME
+export JAVA_HOME
+export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
+
+cd "$WATCH_ROOT"
+
+"$FLUTTER_BIN" --disable-analytics pub get
+set +e
+"$FLUTTER_BIN" build apk "--$MODE"
+BUILD_STATUS=$?
+set -e
+
+APK_PATH="$WATCH_ROOT/build/app/outputs/flutter-apk/app-$MODE.apk"
+ALT_APK_PATH="$WATCH_ROOT/build/watch_app/app/outputs/flutter-apk/app-$MODE.apk"
+LEGACY_ALT_APK_PATH="$WATCH_ROOT/build/watch_app/app/outputs/apk/$MODE/app-$MODE.apk"
+CANONICAL_APK_PATH="$WATCH_ROOT/build/app/outputs/flutter-apk/app-$MODE.apk"
+
+copy_to_canonical_path() {
+ local source_apk="$1"
+ mkdir -p "$(dirname "$CANONICAL_APK_PATH")"
+ if [[ "$source_apk" == "$CANONICAL_APK_PATH" ]]; then
+ echo "APK already at canonical path: $CANONICAL_APK_PATH"
+ return 0
+ fi
+ cp "$source_apk" "$CANONICAL_APK_PATH"
+ echo "APK copied to canonical path: $CANONICAL_APK_PATH"
+}
+
+if [[ -f "$APK_PATH" ]]; then
+ copy_to_canonical_path "$APK_PATH"
+ echo "APK built at: $APK_PATH"
+ exit 0
+fi
+
+if [[ -f "$ALT_APK_PATH" ]]; then
+ copy_to_canonical_path "$ALT_APK_PATH"
+ echo "APK built at: $ALT_APK_PATH"
+ exit 0
+fi
+
+if [[ -f "$LEGACY_ALT_APK_PATH" ]]; then
+ copy_to_canonical_path "$LEGACY_ALT_APK_PATH"
+ echo "APK built at: $LEGACY_ALT_APK_PATH"
+ exit 0
+fi
+
+if [[ $BUILD_STATUS -ne 0 ]]; then
+ exit "$BUILD_STATUS"
+fi
+
+echo "APK built at: $APK_PATH"
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',