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>
@ -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" />
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?> {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
@ -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>
|
||||
@ -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>
|
||||
BIN
watch_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
watch_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
watch_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
watch_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@ -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>
|
||||
|
||||
4
watch_app/android/app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#080A12</color>
|
||||
</resources>
|
||||
@ -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>
|
||||
|
||||