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