feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre

Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 05:56:12 +02:00
parent c65a5a76a9
commit 65d43b9768
80 changed files with 12292 additions and 892 deletions

View File

@ -4,16 +4,23 @@
android:required="true" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-permission android:name="android.permission.BODY_SENSORS_BACKGROUND" />
<application
android:label="GameTime"
android:name="${applicationName}"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/LaunchTheme"
android:usesCleartextTraffic="false">
<uses-library
android:name="com.google.android.wearable"
android:required="true" />
<meta-data
android:name="com.google.android.wearable.capabilities"
android:resource="@array/android_wear_capabilities" />
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />

View File

@ -1,12 +1,40 @@
package com.gametime.watch
import android.os.Bundle
import androidx.wear.ambient.AmbientModeSupport
import com.gametime.watch.bridge.WatchBridgePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
class MainActivity :
FlutterFragmentActivity(),
AmbientModeSupport.AmbientCallbackProvider {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AmbientModeSupport.attach(this)
WatchBridgePlugin.attachActivity(this)
}
override fun onDestroy() {
WatchBridgePlugin.detachActivity(this)
super.onDestroy()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
}
override fun getAmbientCallback(): AmbientModeSupport.AmbientCallback {
return object : AmbientModeSupport.AmbientCallback() {}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
WatchBridgePlugin.handlePermissionResult(requestCode, grantResults)
}
}

View File

@ -1,7 +1,11 @@
package com.gametime.watch.bridge
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataMapItem
@ -17,18 +21,32 @@ import java.nio.charset.StandardCharsets
object WatchBridgePlugin {
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
private const val ACK_CHANNEL = "gametime.watch_bridge/acks"
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command"
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion"
private const val BODY_SENSORS_PERMISSION_REQUEST = 4106
private var appContext: Context? = null
private var activity: Activity? = null
private var projectionSink: EventChannel.EventSink? = null
private var sensorSampleSink: EventChannel.EventSink? = null
private var ackSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val heartRateCollector = WatchHeartRateCollector(
phoneCapability = PHONE_CAPABILITY,
sensorSummaryPath = SENSOR_SUMMARY_PATH,
sensorSamplePath = SENSOR_SAMPLE_PATH,
onLocalSample = ::emitSensorSample,
)
private var bodySensorPermissionRequested = false
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
@ -59,6 +77,18 @@ object WatchBridgePlugin {
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
sensorSampleSink = events
}
override fun onCancel(arguments: Any?) {
sensorSampleSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
@ -74,25 +104,63 @@ object WatchBridgePlugin {
)
}
fun attachActivity(activity: Activity) {
this.activity = activity
}
fun detachActivity(activity: Activity) {
if (this.activity === activity) {
this.activity = null
}
}
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
if (requestCode != BODY_SENSORS_PERMISSION_REQUEST) {
return
}
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
}
}
fun emitProjection(payload: Map<String, Any?>): Boolean {
appContext?.let {
WatchOngoingActivityController.update(it, payload, activity)
updateHeartRateCollection(it, payload)
}
val sink = projectionSink ?: return false
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitAck(payload: Map<String, Any?>): Boolean {
val sink = ackSink ?: return false
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitSensorSample(payload: Map<String, Any?>): Boolean {
val sink = sensorSampleSink ?: return false
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
connectionSink?.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
val sink = connectionSink ?: return
mainHandler.post {
sink.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
}
}
fun handleDataEvent(event: DataEvent) {
@ -105,7 +173,8 @@ object WatchBridgePlugin {
.dataMap
.getString("projectionJson")
?: return
emitProjection(JSONObject(projectionJson).toMap())
val projection = JSONObject(projectionJson).toMap()
emitProjection(projection)
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
@ -194,6 +263,7 @@ object WatchBridgePlugin {
fun requestLatestProjection(context: Context) {
val uri = Uri.Builder()
.scheme("wear")
.authority("*")
.path(STATE_PATH)
.build()
Wearable.getDataClient(context)
@ -212,6 +282,67 @@ object WatchBridgePlugin {
}
}
}
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
heartRateCollector.finishCurrentSession(context)
return
}
val shouldAggregate = phase == "running"
if (!hasBodySensorsPermission(context)) {
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate = false,
executionContext = telemetryContext(projection),
)
requestBodySensorsPermissionOnce()
return
}
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate,
executionContext = telemetryContext(projection),
)
if (shouldAggregate) {
heartRateCollector.start(context)
} else {
heartRateCollector.pause(context)
}
}
private fun hasBodySensorsPermission(context: Context): Boolean {
return context.checkSelfPermission(android.Manifest.permission.BODY_SENSORS) ==
PackageManager.PERMISSION_GRANTED
}
private fun requestBodySensorsPermissionOnce() {
val activity = activity ?: return
if (bodySensorPermissionRequested) {
return
}
bodySensorPermissionRequested = true
activity.requestPermissions(
arrayOf(android.Manifest.permission.BODY_SENSORS),
BODY_SENSORS_PERMISSION_REQUEST,
)
}
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
return mapOf(
"programIndex" to projection["programIndex"],
"exerciseIndex" to projection["exerciseIndex"],
"setIndex" to projection["setIndex"],
"passageIndex" to zeroBasedNullableIndex(projection["passageIndex"]),
"stepIndex" to zeroBasedNullableIndex(projection["stepIndex"]),
)
}
private fun zeroBasedNullableIndex(value: Any?): Int? {
val index = (value as? Number)?.toInt() ?: return null
return if (index > 0) index - 1 else null
}
}
private fun JSONObject.toMap(): Map<String, Any?> {

View File

@ -0,0 +1,186 @@
package com.gametime.watch.bridge
import android.content.Context
import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureCallback
import androidx.health.services.client.data.Availability
import androidx.health.services.client.data.DataPointContainer
import androidx.health.services.client.data.DataType
import androidx.health.services.client.data.DeltaDataType
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Wearable
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import kotlin.math.roundToInt
internal class WatchHeartRateCollector(
private val phoneCapability: String,
private val sensorSummaryPath: String,
private val sensorSamplePath: String,
private val onLocalSample: (Map<String, Any?>) -> Unit = {},
) {
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 sampleSequence = 0
private var executionContext: Map<String, Any?> = emptyMap()
private var registered = false
private var shouldAggregate = false
private var appContext: Context? = null
private val callback = object : MeasureCallback {
override fun onAvailabilityChanged(
dataType: DeltaDataType<*, *>,
availability: Availability,
) = Unit
override fun onDataReceived(data: DataPointContainer) {
if (!shouldAggregate) {
return
}
for (point in data.getData(DataType.HEART_RATE_BPM)) {
record(point.value)
}
}
override fun onRegistrationFailed(throwable: Throwable) {
registered = false
}
}
fun noteActiveSession(
nextSessionId: String,
shouldAggregate: Boolean,
executionContext: Map<String, Any?> = emptyMap(),
) {
if (sessionId != nextSessionId) {
reset(nextSessionId)
}
this.executionContext = executionContext
this.shouldAggregate = shouldAggregate
}
fun onBodySensorsGranted(context: Context) {
if (shouldAggregate) {
start(context)
}
}
fun start(context: Context) {
if (registered || sessionId.isNullOrBlank()) {
return
}
appContext = context.applicationContext
try {
HealthServices.getClient(context)
.measureClient
.registerMeasureCallback(DataType.HEART_RATE_BPM, callback)
registered = true
} catch (_: RuntimeException) {
registered = false
}
}
fun pause(context: Context) {
shouldAggregate = false
unregister(context)
}
fun finishCurrentSession(context: Context) {
unregister(context)
val completedSessionId = sessionId
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
sendSummary(context, completedSessionId)
}
reset(null)
}
private fun record(bpm: Double) {
if (bpm <= 0) {
return
}
sampleCount += 1
sampleSum += bpm
val rounded = bpm.roundToInt()
minBpm = minOf(minBpm ?: rounded, rounded)
maxBpm = maxOf(maxBpm ?: rounded, rounded)
sendSample(rounded)
}
private fun sendSample(bpm: Int) {
val activeSessionId = sessionId ?: return
val context = appContext ?: return
sampleSequence += 1
val capturedAt = System.currentTimeMillis()
val sample = mapOf(
"schemaVersion" to 4,
"sampleId" to "$activeSessionId-$capturedAt-$sampleSequence",
"sessionId" to activeSessionId,
"capturedAtEpochMs" to capturedAt,
"recordedAtEpochMs" to capturedAt,
"programIndex" to executionContext["programIndex"],
"exerciseIndex" to executionContext["exerciseIndex"],
"setIndex" to executionContext["setIndex"],
"passageIndex" to executionContext["passageIndex"],
"stepIndex" to executionContext["stepIndex"],
"heartRateBpm" to bpm,
)
onLocalSample(sample)
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
for (node in capability.nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSamplePath, payload)
}
}
}
private fun sendSummary(context: Context, completedSessionId: String) {
val min = minBpm ?: return
val max = maxBpm ?: return
val payload = JSONObject(
mapOf(
"schemaVersion" to 4,
"sessionId" to completedSessionId,
"sampleCount" to sampleCount,
"minHeartRateBpm" to min,
"averageHeartRateBpm" to sampleSum / sampleCount,
"maxHeartRateBpm" to max,
),
).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
for (node in capability.nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSummaryPath, payload)
}
}
}
private fun unregister(context: Context) {
if (!registered) {
return
}
HealthServices.getClient(context)
.measureClient
.unregisterMeasureCallbackAsync(DataType.HEART_RATE_BPM, callback)
registered = false
appContext = null
}
private fun reset(nextSessionId: String?) {
sessionId = nextSessionId
sampleCount = 0
sampleSum = 0.0
minBpm = null
maxBpm = null
sampleSequence = 0
executionContext = emptyMap()
shouldAggregate = false
}
}

View File

@ -0,0 +1,125 @@
package com.gametime.watch.bridge
import android.Manifest
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.wear.ongoing.OngoingActivity
import androidx.wear.ongoing.Status
import com.gametime.watch.MainActivity
import com.gametime.watch.R
object WatchOngoingActivityController {
private const val CHANNEL_ID = "gametime_watch_session"
private const val CHANNEL_NAME = "Séance GameTime"
private const val NOTIFICATION_ID = 9101
private const val ONGOING_ACTIVITY_ID = 9101
private const val POST_NOTIFICATIONS_PERMISSION_REQUEST = 4107
private var postNotificationsPermissionRequested = false
fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
cancel(context)
return
}
if (!hasPostNotificationsPermission(context)) {
requestPostNotificationsPermissionOnce(activity)
return
}
post(context, projection)
}
fun cancel(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
private fun post(context: Context, projection: Map<String, Any?>) {
ensureNotificationChannel(context)
val touchIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val exerciseName = (projection["exerciseName"] as? String)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
val status = Status.Builder()
.addPart("activity", Status.TextPart(exerciseName))
.addTemplate("#activity#")
.build()
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_ongoing_gt)
.setContentTitle("GameTime")
.setContentText(exerciseName)
.setContentIntent(touchIntent)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
OngoingActivity.Builder(context, NOTIFICATION_ID, notificationBuilder)
.setOngoingActivityId(ONGOING_ACTIVITY_ID)
.setStaticIcon(R.drawable.ic_ongoing_gt)
.setTouchIntent(touchIntent)
.setTitle("GameTime")
.setStatus(status)
.build()
.apply(context)
NotificationManagerCompat.from(context).notify(
NOTIFICATION_ID,
notificationBuilder.build(),
)
}
private fun ensureNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = context.getSystemService(NotificationManager::class.java)
val existing = manager.getNotificationChannel(CHANNEL_ID)
if (existing != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
),
)
}
private fun hasPostNotificationsPermission(context: Context): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
private fun requestPostNotificationsPermissionOnce(activity: Activity?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
activity == null ||
postNotificationsPermissionRequested
) {
return
}
postNotificationsPermissionRequested = true
activity.requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
POST_NOTIFICATIONS_PERMISSION_REQUEST,
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,4h14v3H11v3h7v3h-7v7H5z" />
</vector>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -2,10 +2,12 @@
<style name="LaunchTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowSwipeToDismiss">false</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">#080A12</item>
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowSwipeToDismiss">false</item>
</style>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#080A12</color>
</resources>

View File

@ -1,9 +1,11 @@
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSwipeToDismiss">false</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">#080A12</item>
<item name="android:windowSwipeToDismiss">false</item>
</style>
</resources>