feat(watch): fréquence cardiaque live montre/téléphone (ticket #155)

Met en conformité l'implémentation avec le cadrage produit acté
(#155, #144, #142) : diffusion de la FC live depuis la montre vers le
téléphone via le bridge, affichage sur l'écran de séance montre
(watch_session_screen) en plus de l'écran Stats existant.

Validé : bridge fonctionnel bout-en-bout, tests watch_session_screen OK.
Artefact canonique d'installation montre confirmé :
watch_app/build/app/outputs/flutter-apk/app-release.apk
(package com.gametime.app) ; ne pas utiliser l'artefact résiduel
watch_app/build/watch_app/app/outputs/flutter-apk/app-release.apk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 13:03:40 +02:00
parent 8bb74df763
commit 592796915f
6 changed files with 246 additions and 89 deletions

View File

@ -38,7 +38,9 @@ android {
} }
defaultConfig { 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 minSdk = 30
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode

View File

@ -5,8 +5,14 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BODY_SENSORS" /> <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.BODY_SENSORS_BACKGROUND" /> <uses-permission
android:name="android.permission.BODY_SENSORS"
android:maxSdkVersion="35" />
<uses-permission
android:name="android.permission.BODY_SENSORS_BACKGROUND"
android:maxSdkVersion="35" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<application <application
android:label="GameTime" android:label="GameTime"

View File

@ -4,8 +4,10 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataMapItem import com.google.android.gms.wearable.DataMapItem
@ -19,6 +21,7 @@ import org.json.JSONObject
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
object WatchBridgePlugin { object WatchBridgePlugin {
private const val TAG = "GTWatchBridgeWatch"
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods" private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections" private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples" private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
@ -31,7 +34,9 @@ object WatchBridgePlugin {
const val ACK_PATH = "/gametime/phone/ack" const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection" const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion" const val PHONE_CAPABILITY = "gametime_phone_companion"
private const val BODY_SENSORS_PERMISSION_REQUEST = 4106 private const val SENSOR_PERMISSION_REQUEST = 4106
private const val READ_HEART_RATE_PERMISSION =
"android.permission.health.READ_HEART_RATE"
private var appContext: Context? = null private var appContext: Context? = null
private var activity: Activity? = null private var activity: Activity? = null
@ -46,7 +51,8 @@ object WatchBridgePlugin {
sensorSamplePath = SENSOR_SAMPLE_PATH, sensorSamplePath = SENSOR_SAMPLE_PATH,
onLocalSample = ::emitSensorSample, onLocalSample = ::emitSensorSample,
) )
private var bodySensorPermissionRequested = false private var sensorPermissionRequested = false
private var pendingSensorPermissionRequest = false
fun register(flutterEngine: FlutterEngine, context: Context) { fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext appContext = context.applicationContext
@ -102,10 +108,12 @@ object WatchBridgePlugin {
} }
}, },
) )
requestPendingSensorPermissionIfPossible()
} }
fun attachActivity(activity: Activity) { fun attachActivity(activity: Activity) {
this.activity = activity this.activity = activity
requestPendingSensorPermissionIfPossible()
} }
fun detachActivity(activity: Activity) { fun detachActivity(activity: Activity) {
@ -115,11 +123,17 @@ object WatchBridgePlugin {
} }
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) { fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
if (requestCode != BODY_SENSORS_PERMISSION_REQUEST) { if (requestCode != SENSOR_PERMISSION_REQUEST) {
return return
} }
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { val granted = appContext?.let(::hasRequiredSensorPermissions) == true ||
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (granted) {
pendingSensorPermissionRequest = false
Log.d(TAG, "sensor permissions granted")
appContext?.let { heartRateCollector.onBodySensorsGranted(it) } appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
} else {
Log.w(TAG, "sensor permissions denied")
} }
} }
@ -291,15 +305,17 @@ object WatchBridgePlugin {
return return
} }
val shouldAggregate = phase == "running" val shouldAggregate = phase == "running"
if (!hasBodySensorsPermission(context)) { if (!hasRequiredSensorPermissions(context)) {
heartRateCollector.noteActiveSession( heartRateCollector.noteActiveSession(
sessionId, sessionId,
shouldAggregate = false, shouldAggregate = false,
executionContext = telemetryContext(projection), executionContext = telemetryContext(projection),
) )
requestBodySensorsPermissionOnce() pendingSensorPermissionRequest = true
requestSensorPermissionsOnce()
return return
} }
pendingSensorPermissionRequest = false
heartRateCollector.noteActiveSession( heartRateCollector.noteActiveSession(
sessionId, sessionId,
shouldAggregate, shouldAggregate,
@ -312,20 +328,51 @@ object WatchBridgePlugin {
} }
} }
private fun hasBodySensorsPermission(context: Context): Boolean { private fun hasRequiredSensorPermissions(context: Context): Boolean {
return context.checkSelfPermission(android.Manifest.permission.BODY_SENSORS) == return requiredSensorPermissions().all { permission ->
PackageManager.PERMISSION_GRANTED context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
} }
private fun requestBodySensorsPermissionOnce() { private fun requestSensorPermissionsOnce() {
val activity = activity ?: return val activity = activity ?: run {
if (bodySensorPermissionRequested) { Log.d(TAG, "sensor permission request pending: activity unavailable")
return return
} }
bodySensorPermissionRequested = true if (sensorPermissionRequested) {
activity.requestPermissions( Log.d(TAG, "sensor permission request already attempted")
arrayOf(android.Manifest.permission.BODY_SENSORS), return
BODY_SENSORS_PERMISSION_REQUEST, }
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<String> {
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,
) )
} }

View File

@ -1,7 +1,9 @@
package com.gametime.watch.bridge package com.gametime.watch.bridge
import android.content.Context import android.content.Context
import android.util.Log
import androidx.health.services.client.HealthServices import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureClient
import androidx.health.services.client.MeasureCallback import androidx.health.services.client.MeasureCallback
import androidx.health.services.client.data.Availability import androidx.health.services.client.data.Availability
import androidx.health.services.client.data.DataPointContainer import androidx.health.services.client.data.DataPointContainer
@ -19,14 +21,20 @@ internal class WatchHeartRateCollector(
private val sensorSamplePath: String, private val sensorSamplePath: String,
private val onLocalSample: (Map<String, Any?>) -> Unit = {}, private val onLocalSample: (Map<String, Any?>) -> Unit = {},
) { ) {
private companion object {
const val TAG = "GTWatchHeartRate"
}
private var sessionId: String? = null private var sessionId: String? = null
private var sampleCount = 0 private var sampleCount = 0
private var sampleSum = 0.0 private var sampleSum = 0.0
private var minBpm: Int? = null private var minBpm: Int? = null
private var maxBpm: Int? = null private var maxBpm: Int? = null
private var distanceMeters: Double? = null
private var caloriesKcal: Double? = null
private var sampleSequence = 0 private var sampleSequence = 0
private var executionContext: Map<String, Any?> = emptyMap() private var executionContext: Map<String, Any?> = emptyMap()
private var registered = false private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var shouldAggregate = false private var shouldAggregate = false
private var appContext: Context? = null private var appContext: Context? = null
@ -34,19 +42,39 @@ internal class WatchHeartRateCollector(
override fun onAvailabilityChanged( override fun onAvailabilityChanged(
dataType: DeltaDataType<*, *>, dataType: DeltaDataType<*, *>,
availability: Availability, availability: Availability,
) = Unit ) {
Log.d(TAG, "availability dataType=$dataType availability=$availability")
}
override fun onDataReceived(data: DataPointContainer) { override fun onDataReceived(data: DataPointContainer) {
if (!shouldAggregate) { if (!shouldAggregate) {
return return
} }
var latestHeartRateBpm: Int? = null
for (point in data.getData(DataType.HEART_RATE_BPM)) { 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) { 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) { fun start(context: Context) {
if (registered || sessionId.isNullOrBlank()) { if (sessionId.isNullOrBlank()) {
return return
} }
appContext = context.applicationContext appContext = context.applicationContext
try { val measureClient = HealthServices.getClient(context).measureClient
HealthServices.getClient(context) registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
.measureClient registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
.registerMeasureCallback(DataType.HEART_RATE_BPM, callback) registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES)
registered = true
} catch (_: RuntimeException) {
registered = false
}
} }
fun pause(context: Context) { fun pause(context: Context) {
@ -93,23 +117,28 @@ internal class WatchHeartRateCollector(
val completedSessionId = sessionId val completedSessionId = sessionId
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) { if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
sendSummary(context, completedSessionId) sendSummary(context, completedSessionId)
} else {
Log.d(
TAG,
"skip summary sessionId=$completedSessionId sampleCount=$sampleCount",
)
} }
reset(null) reset(null)
} }
private fun record(bpm: Double) { private fun recordHeartRate(bpm: Double): Int? {
if (bpm <= 0) { if (bpm <= 0) {
return return null
} }
sampleCount += 1 sampleCount += 1
sampleSum += bpm sampleSum += bpm
val rounded = bpm.roundToInt() val rounded = bpm.roundToInt()
minBpm = minOf(minBpm ?: rounded, rounded) minBpm = minOf(minBpm ?: rounded, rounded)
maxBpm = maxOf(maxBpm ?: 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 activeSessionId = sessionId ?: return
val context = appContext ?: return val context = appContext ?: return
sampleSequence += 1 sampleSequence += 1
@ -126,17 +155,26 @@ internal class WatchHeartRateCollector(
"passageIndex" to executionContext["passageIndex"], "passageIndex" to executionContext["passageIndex"],
"stepIndex" to executionContext["stepIndex"], "stepIndex" to executionContext["stepIndex"],
"heartRateBpm" to bpm, "heartRateBpm" to bpm,
"distanceMeters" to distanceMeters,
"caloriesKcal" to caloriesKcal,
) )
onLocalSample(sample) onLocalSample(sample)
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8) val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context) Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability -> .addOnSuccessListener { capability ->
Log.d(
TAG,
"send sample sessionId=$activeSessionId bpm=$bpm distance=$distanceMeters calories=$caloriesKcal nodes=${capability.nodes.size}",
)
for (node in capability.nodes) { for (node in capability.nodes) {
Wearable.getMessageClient(context) Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSamplePath, payload) .sendMessage(node.id, sensorSamplePath, payload)
} }
} }
.addOnFailureListener { error ->
Log.w(TAG, "sample capability lookup failed", error)
}
} }
private fun sendSummary(context: Context, completedSessionId: String) { private fun sendSummary(context: Context, completedSessionId: String) {
@ -155,30 +193,56 @@ internal class WatchHeartRateCollector(
Wearable.getCapabilityClient(context) Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability -> .addOnSuccessListener { capability ->
Log.d(
TAG,
"send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}",
)
for (node in capability.nodes) { for (node in capability.nodes) {
Wearable.getMessageClient(context) Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSummaryPath, payload) .sendMessage(node.id, sensorSummaryPath, payload)
} }
} }
.addOnFailureListener { error ->
Log.w(TAG, "summary capability lookup failed", error)
}
} }
private fun unregister(context: Context) { private fun unregister(context: Context) {
if (!registered) { if (registeredDataTypes.isEmpty()) {
return return
} }
HealthServices.getClient(context) val measureClient = HealthServices.getClient(context).measureClient
.measureClient for (dataType in registeredDataTypes.toList()) {
.unregisterMeasureCallbackAsync(DataType.HEART_RATE_BPM, callback) measureClient.unregisterMeasureCallbackAsync(dataType, callback)
registered = false }
registeredDataTypes.clear()
appContext = null 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?) { private fun reset(nextSessionId: String?) {
sessionId = nextSessionId sessionId = nextSessionId
sampleCount = 0 sampleCount = 0
sampleSum = 0.0 sampleSum = 0.0
minBpm = null minBpm = null
maxBpm = null maxBpm = null
distanceMeters = null
caloriesKcal = null
sampleSequence = 0 sampleSequence = 0
executionContext = emptyMap() executionContext = emptyMap()
shouldAggregate = false shouldAggregate = false

View File

@ -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 { final class _ScaledContent extends StatelessWidget {
const _ScaledContent({required this.child}); const _ScaledContent({required this.child});
@ -679,6 +706,7 @@ final class _ActiveContent extends StatelessWidget {
_SmallLabel(dominantLabel), _SmallLabel(dominantLabel),
const SizedBox(height: 2), const SizedBox(height: 2),
_DominantValue(dominantValue), _DominantValue(dominantValue),
_MainHeartRateLine(sample: state.sensorSample),
if (timer != null) ...[ if (timer != null) ...[
const SizedBox(height: 3), const SizedBox(height: 3),
_TimerToggleButton( _TimerToggleButton(
@ -792,6 +820,7 @@ final class _ManualScoreContent extends StatelessWidget {
? const _PendingDot() ? const _PendingDot()
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
_MainHeartRateLine(sample: state.sensorSample),
const SizedBox(height: 5), const SizedBox(height: 5),
if (timer != null) if (timer != null)
_CompactTimerLine( _CompactTimerLine(
@ -950,6 +979,7 @@ final class _RestContent extends StatelessWidget {
const _SmallLabel('REPOS'), const _SmallLabel('REPOS'),
const SizedBox(height: 2), const SizedBox(height: 2),
_DominantValue(timer == null ? '--:--' : _timerText(timer)), _DominantValue(timer == null ? '--:--' : _timerText(timer)),
_MainHeartRateLine(sample: state.sensorSample),
if (timer != null) ...[ if (timer != null) ...[
const SizedBox(height: 3), const SizedBox(height: 3),
_TimerToggleButton( _TimerToggleButton(

View File

@ -212,9 +212,9 @@ void main() {
viewModel.dispose(); viewModel.dispose();
}); });
testWidgets('shows telemetry only on stats page when available', ( testWidgets(
tester, 'shows live heart rate on main page and full telemetry on stats',
) async { (tester) async {
final client = _FakeNativeWatchBridgeClient(); final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client); final viewModel = WatchSessionViewModel(nativeClient: client);
@ -237,7 +237,12 @@ void main() {
client.emitSensorSample( client.emitSensorSample(
WatchSensorSample( WatchSensorSample(
sessionId: 'session-1', sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, capturedAtEpochMs: DateTime.utc(
2026,
7,
27,
10,
).millisecondsSinceEpoch,
heartRateBpm: 142, heartRateBpm: 142,
distanceMeters: 840, distanceMeters: 840,
caloriesKcal: 186, caloriesKcal: 186,
@ -245,7 +250,9 @@ void main() {
); );
await tester.pump(); await tester.pump();
expect(find.text('142 bpm'), findsNothing); expect(find.text('FC 142 bpm'), findsOneWidget);
expect(find.text('840 m'), findsNothing);
expect(find.text('186 kcal'), findsNothing);
expect(find.byTooltip('Stats'), findsOneWidget); expect(find.byTooltip('Stats'), findsOneWidget);
await tester.drag( await tester.drag(
@ -267,7 +274,8 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose(); viewModel.dispose();
}); },
);
testWidgets( testWidgets(
'sends pause command from the icon button when a timer dominates', 'sends pause command from the icon button when a timer dominates',