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

@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
@ -10,6 +12,9 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<meta-data
android:name="com.google.android.wearable.capabilities"
android:resource="@array/android_wear_capabilities" />
<activity
android:name=".MainActivity"
android:exported="true"
@ -41,6 +46,14 @@
android:name=".watch.WatchCompanionForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".session.SessionStatusForegroundService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="workout-session-status" />
</service>
<service
android:name=".watch.PhoneWatchBridgeListenerService"
android:exported="true">

View File

@ -1,5 +1,6 @@
package com.gametime.app
import com.gametime.app.session.SessionNotificationPlugin
import com.gametime.app.watch.WatchBridgePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
@ -8,5 +9,6 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
SessionNotificationPlugin.register(flutterEngine, this)
}
}

View File

@ -0,0 +1,90 @@
package com.gametime.app.session
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.lang.ref.WeakReference
object SessionNotificationPlugin {
private const val METHOD_CHANNEL = "gametime.session_notification/methods"
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 9210
private var appContext: Context? = null
private var activityRef: WeakReference<Activity>? = null
private var notificationPermissionRequested = false
fun register(flutterEngine: FlutterEngine, activity: Activity) {
appContext = activity.applicationContext
activityRef = WeakReference(activity)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(::handleMethodCall)
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
val context = appContext
if (context == null) {
result.error("session_notification_unavailable", "Application context unavailable.", null)
return
}
when (call.method) {
"show" -> show(context, call.arguments, result)
"clear" -> {
context.stopService(Intent(context, SessionStatusForegroundService::class.java))
result.success(null)
}
else -> result.notImplemented()
}
}
private fun show(context: Context, arguments: Any?, result: MethodChannel.Result) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_notification", "Notification payload must be a map.", null)
return
}
requestPostNotificationsPermissionIfNeeded()
val intent = Intent(context, SessionStatusForegroundService::class.java).apply {
action = SessionStatusForegroundService.ACTION_SHOW
putExtra(SessionStatusForegroundService.EXTRA_TITLE, map["title"] as? String ?: "GameTime")
putExtra(
SessionStatusForegroundService.EXTRA_PRIMARY_LINE,
map["primaryLine"] as? String ?: "Séance en cours",
)
putExtra(
SessionStatusForegroundService.EXTRA_SECONDARY_LINE,
map["secondaryLine"] as? String,
)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
result.success(null)
}
private fun requestPostNotificationsPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
notificationPermissionRequested
) {
return
}
val activity = activityRef?.get() ?: return
if (activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
return
}
notificationPermissionRequested = true
activity.requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
}
}

View File

@ -0,0 +1,100 @@
package com.gametime.app.session
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.gametime.app.MainActivity
import com.gametime.app.R
class SessionStatusForegroundService : Service() {
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_SHOW) {
stopSelf()
return START_NOT_STICKY
}
val title = intent.getStringExtra(EXTRA_TITLE) ?: "GameTime"
val primaryLine = intent.getStringExtra(EXTRA_PRIMARY_LINE) ?: "Séance en cours"
val secondaryLine = intent.getStringExtra(EXTRA_SECONDARY_LINE)
startForeground(
NOTIFICATION_ID,
notification(
title = title,
primaryLine = primaryLine,
secondaryLine = secondaryLine,
),
)
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun notification(
title: String,
primaryLine: String,
secondaryLine: String?,
): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val style = secondaryLine?.takeIf { it.isNotBlank() }?.let {
Notification.BigTextStyle().bigText("$primaryLine\n$it")
}
return builder
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(primaryLine)
.setSubText(secondaryLine)
.setStyle(style)
.setContentIntent(contentIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_STATUS)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
"Séance en cours",
NotificationManager.IMPORTANCE_LOW,
)
manager.createNotificationChannel(channel)
}
companion object {
const val ACTION_SHOW = "com.gametime.app.session.SHOW"
const val EXTRA_TITLE = "title"
const val EXTRA_PRIMARY_LINE = "primaryLine"
const val EXTRA_SECONDARY_LINE = "secondaryLine"
private const val CHANNEL_ID = "gametime_session_status"
private const val NOTIFICATION_ID = 92
}
}

View File

@ -9,14 +9,21 @@ import java.nio.charset.StandardCharsets
class PhoneWatchBridgeListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != WatchBridgePlugin.COMMAND_PATH) {
return
}
val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8))
val command = payload.toMap()
val delivered = WatchBridgePlugin.emitCommand(command, messageEvent.sourceNodeId)
if (!delivered) {
sendPhoneBusyAck(command, messageEvent.sourceNodeId)
when (messageEvent.path) {
WatchBridgePlugin.COMMAND_PATH -> {
val command = payload.toMap()
val delivered = WatchBridgePlugin.emitCommand(command, messageEvent.sourceNodeId)
if (!delivered) {
sendPhoneBusyAck(command, messageEvent.sourceNodeId)
}
}
WatchBridgePlugin.SENSOR_SUMMARY_PATH -> {
WatchBridgePlugin.emitSensorSummary(payload.toMap())
}
WatchBridgePlugin.SENSOR_SAMPLE_PATH -> {
WatchBridgePlugin.emitSensorSample(payload.toMap())
}
}
}

View File

@ -2,6 +2,8 @@ package com.gametime.app.watch
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
@ -16,9 +18,13 @@ import java.util.concurrent.ConcurrentHashMap
object WatchBridgePlugin {
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val COMMAND_CHANNEL = "gametime.watch_bridge/commands"
private const val SENSOR_SUMMARY_CHANNEL = "gametime.watch_bridge/sensor_summaries"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
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 WATCH_CAPABILITY = "gametime_watch_companion"
@ -26,7 +32,10 @@ object WatchBridgePlugin {
private val pendingCommandNodes = ConcurrentHashMap<String, String>()
private var appContext: Context? = null
private var commandSink: EventChannel.EventSink? = null
private var sensorSummarySink: EventChannel.EventSink? = null
private var sensorSampleSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
@ -44,6 +53,30 @@ object WatchBridgePlugin {
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SUMMARY_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
sensorSummarySink = events
}
override fun onCancel(arguments: Any?) {
sensorSummarySink = null
}
},
)
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 {
@ -65,17 +98,38 @@ object WatchBridgePlugin {
if (commandId != null) {
pendingCommandNodes[commandId] = sourceNodeId
}
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitSensorSummary(payload: Map<String, Any?>): Boolean {
val sink = sensorSummarySink ?: return false
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,
),
)
}
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {