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:
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user