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>
@ -15,8 +15,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.gametime.watch"
|
||||
minSdk = 26
|
||||
applicationId = "com.gametime.app"
|
||||
minSdk = 30
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
@ -29,10 +29,21 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.health:health-services-client:1.1.0-rc02")
|
||||
implementation("androidx.wear:wear:1.4.0")
|
||||
implementation("androidx.wear:wear-ongoing:1.0.0")
|
||||
implementation("com.google.guava:guava:33.6.0-android")
|
||||
implementation("com.google.android.gms:play-services-wearable:19.0.0")
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -6,9 +6,8 @@ allprojects {
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build/watch_app")
|
||||
.get()
|
||||
rootProject.layout.projectDirectory
|
||||
.dir("../build")
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
|
||||
4
watch_app/android/gradle.properties
Normal file
@ -0,0 +1,4 @@
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
BIN
watch_app/assets/fonts/Anton-Regular.ttf
Normal file
BIN
watch_app/assets/fonts/Archivo-Variable.ttf
Normal file
@ -11,17 +11,31 @@ final class WatchSessionUiState {
|
||||
required this.projection,
|
||||
this.commandPending = false,
|
||||
this.waitingForPhone = false,
|
||||
this.timerTogglePending = false,
|
||||
this.connectionLost = false,
|
||||
this.staleProjection = false,
|
||||
this.scoreCommandPending = false,
|
||||
this.scoreWaitingForPhone = false,
|
||||
this.optimisticManualScoreValue,
|
||||
this.commandFailureMessage,
|
||||
this.commandFailureSerial = 0,
|
||||
this.lastAck,
|
||||
this.sensorSample,
|
||||
});
|
||||
|
||||
final WatchSessionProjection projection;
|
||||
final bool commandPending;
|
||||
final bool waitingForPhone;
|
||||
final bool timerTogglePending;
|
||||
final bool connectionLost;
|
||||
final bool staleProjection;
|
||||
final bool scoreCommandPending;
|
||||
final bool scoreWaitingForPhone;
|
||||
final double? optimisticManualScoreValue;
|
||||
final String? commandFailureMessage;
|
||||
final int commandFailureSerial;
|
||||
final WatchCommandAckEvent? lastAck;
|
||||
final WatchSensorSample? sensorSample;
|
||||
|
||||
bool get actionsEnabled => !commandPending && !connectionLost;
|
||||
|
||||
@ -29,19 +43,50 @@ final class WatchSessionUiState {
|
||||
WatchSessionProjection? projection,
|
||||
bool? commandPending,
|
||||
bool? waitingForPhone,
|
||||
bool? timerTogglePending,
|
||||
bool? connectionLost,
|
||||
bool? staleProjection,
|
||||
bool? scoreCommandPending,
|
||||
bool? scoreWaitingForPhone,
|
||||
double? optimisticManualScoreValue,
|
||||
bool clearOptimisticManualScoreValue = false,
|
||||
String? commandFailureMessage,
|
||||
bool clearCommandFailureMessage = false,
|
||||
int? commandFailureSerial,
|
||||
WatchCommandAckEvent? lastAck,
|
||||
WatchSensorSample? sensorSample,
|
||||
bool clearSensorSample = false,
|
||||
}) {
|
||||
return WatchSessionUiState(
|
||||
projection: projection ?? this.projection,
|
||||
commandPending: commandPending ?? this.commandPending,
|
||||
waitingForPhone: waitingForPhone ?? this.waitingForPhone,
|
||||
timerTogglePending: timerTogglePending ?? this.timerTogglePending,
|
||||
connectionLost: connectionLost ?? this.connectionLost,
|
||||
staleProjection: staleProjection ?? this.staleProjection,
|
||||
scoreCommandPending: scoreCommandPending ?? this.scoreCommandPending,
|
||||
scoreWaitingForPhone: scoreWaitingForPhone ?? this.scoreWaitingForPhone,
|
||||
optimisticManualScoreValue: clearOptimisticManualScoreValue
|
||||
? null
|
||||
: optimisticManualScoreValue ?? this.optimisticManualScoreValue,
|
||||
commandFailureMessage: clearCommandFailureMessage
|
||||
? null
|
||||
: commandFailureMessage ?? this.commandFailureMessage,
|
||||
commandFailureSerial: commandFailureSerial ?? this.commandFailureSerial,
|
||||
lastAck: lastAck ?? this.lastAck,
|
||||
sensorSample: clearSensorSample
|
||||
? null
|
||||
: sensorSample ?? this.sensorSample,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasLiveSensors {
|
||||
final sample = sensorSample;
|
||||
return sample != null &&
|
||||
(sample.heartRateBpm != null ||
|
||||
sample.distanceMeters != null ||
|
||||
sample.caloriesKcal != null);
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
@ -59,6 +104,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
_connectionLostThreshold = connectionLostThreshold,
|
||||
super(WatchSessionUiState(projection: _initialProjection())) {
|
||||
_subscriptions.add(_nativeClient.projections.listen(_handleProjection));
|
||||
_subscriptions.add(_nativeClient.sensorSamples.listen(_handleSensorSample));
|
||||
_subscriptions.add(_nativeClient.acks.listen(_handleAck));
|
||||
_subscriptions.add(
|
||||
_nativeClient.connectionEvents.listen(_handleConnectionEvent),
|
||||
@ -79,10 +125,16 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
|
||||
Timer? _waitingTimer;
|
||||
Timer? _commandTimeoutTimer;
|
||||
Timer? _scoreWaitingTimer;
|
||||
Timer? _scoreCommandTimeoutTimer;
|
||||
Timer? _freshnessTimer;
|
||||
Timer? _commandFailureClearTimer;
|
||||
WatchCommandEnvelope? _pendingCommand;
|
||||
final _pendingScoreCommandIds = <String>{};
|
||||
double? _optimisticManualScoreValue;
|
||||
DateTime? _lastProjectionReceivedAt;
|
||||
var _commandCounter = 0;
|
||||
var _commandFailureSerial = 0;
|
||||
|
||||
Future<void> refresh() async {
|
||||
value = value.copyWith(connectionLost: false);
|
||||
@ -119,18 +171,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
WatchSecondaryAction.skipCurrentStep => WatchCommandType.skipCurrentStep,
|
||||
WatchSecondaryAction.skipCurrentPassage =>
|
||||
WatchCommandType.skipCurrentPassage,
|
||||
WatchSecondaryAction.finishCurrentSet => WatchCommandType.finishCurrentSet,
|
||||
WatchSecondaryAction.finishCurrentSet =>
|
||||
WatchCommandType.finishCurrentSet,
|
||||
WatchSecondaryAction.skipCurrentSet => WatchCommandType.skipCurrentSet,
|
||||
WatchSecondaryAction.skipCurrentRest => WatchCommandType.skipCurrentRest,
|
||||
};
|
||||
return _sendCommand(command);
|
||||
}
|
||||
|
||||
Future<void> incrementScore() {
|
||||
return _sendScoreCommand(WatchCommandType.incrementScore, 1);
|
||||
}
|
||||
|
||||
Future<void> decrementScore() {
|
||||
return _sendScoreCommand(WatchCommandType.decrementScore, -1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_waitingTimer?.cancel();
|
||||
_commandTimeoutTimer?.cancel();
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_freshnessTimer?.cancel();
|
||||
_commandFailureClearTimer?.cancel();
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
@ -138,7 +202,9 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
}
|
||||
|
||||
Future<void> _sendCommand(WatchCommandType type) async {
|
||||
if (!value.actionsEnabled || value.projection.deviceSessionId.isEmpty) {
|
||||
if (!value.actionsEnabled ||
|
||||
(_requiresActiveSession(type) &&
|
||||
value.projection.deviceSessionId.isEmpty)) {
|
||||
return;
|
||||
}
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
@ -153,6 +219,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
value = value.copyWith(
|
||||
commandPending: true,
|
||||
waitingForPhone: false,
|
||||
timerTogglePending: _isTimerToggleCommand(type),
|
||||
connectionLost: false,
|
||||
);
|
||||
_waitingTimer?.cancel();
|
||||
@ -165,6 +232,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
value = value.copyWith(
|
||||
commandPending: false,
|
||||
waitingForPhone: false,
|
||||
timerTogglePending: false,
|
||||
connectionLost: true,
|
||||
);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
@ -177,25 +245,114 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
value = value.copyWith(
|
||||
commandPending: false,
|
||||
waitingForPhone: false,
|
||||
timerTogglePending: false,
|
||||
connectionLost: true,
|
||||
);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendScoreCommand(WatchCommandType type, int delta) async {
|
||||
final projection = value.projection;
|
||||
if (value.connectionLost ||
|
||||
!projection.phoneReachable ||
|
||||
!projection.hasManualScore ||
|
||||
projection.deviceSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final current =
|
||||
_optimisticManualScoreValue ?? projection.currentManualScoreValue ?? 0;
|
||||
final next = (current + delta).clamp(0, double.infinity).toDouble();
|
||||
if (next == current) {
|
||||
return;
|
||||
}
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
final command = WatchCommandEnvelope(
|
||||
commandId: 'watch-$nowMs-${_commandCounter++}',
|
||||
type: type,
|
||||
sessionId: projection.deviceSessionId,
|
||||
expectedRevision: projection.revision,
|
||||
sentAtEpochMs: nowMs,
|
||||
);
|
||||
_pendingScoreCommandIds.add(command.commandId);
|
||||
_optimisticManualScoreValue = next;
|
||||
value = value.copyWith(
|
||||
scoreCommandPending: true,
|
||||
scoreWaitingForPhone: false,
|
||||
optimisticManualScoreValue: next,
|
||||
connectionLost: false,
|
||||
);
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreWaitingTimer = Timer(_waitingThreshold, () {
|
||||
value = value.copyWith(scoreWaitingForPhone: true);
|
||||
});
|
||||
_scoreCommandTimeoutTimer = Timer(_commandTimeout, () {
|
||||
_clearScorePending(recalibrate: true);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
unawaited(_nativeClient.requestResync());
|
||||
});
|
||||
try {
|
||||
await _nativeClient.sendCommand(command);
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
} on PlatformException {
|
||||
_pendingScoreCommandIds.remove(command.commandId);
|
||||
_clearScorePending(recalibrate: true);
|
||||
value = value.copyWith(connectionLost: true);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
}
|
||||
}
|
||||
|
||||
void _handleProjection(WatchSessionProjection projection) {
|
||||
final previousProjection = value.projection;
|
||||
_lastProjectionReceivedAt = DateTime.now();
|
||||
_pendingCommand = null;
|
||||
_clearCommandTimers();
|
||||
_syncScorePendingFromProjection(projection);
|
||||
value = WatchSessionUiState(
|
||||
projection: projection,
|
||||
timerTogglePending: false,
|
||||
scoreCommandPending: _pendingScoreCommandIds.isNotEmpty,
|
||||
scoreWaitingForPhone:
|
||||
_pendingScoreCommandIds.isNotEmpty && value.scoreWaitingForPhone,
|
||||
optimisticManualScoreValue: _optimisticManualScoreValue,
|
||||
commandFailureMessage: value.commandFailureMessage,
|
||||
commandFailureSerial: value.commandFailureSerial,
|
||||
lastAck: value.lastAck,
|
||||
sensorSample: projection.deviceSessionId.isEmpty
|
||||
? null
|
||||
: value.sensorSample?.sessionId == projection.deviceSessionId
|
||||
? value.sensorSample
|
||||
: null,
|
||||
);
|
||||
_triggerProjectionHaptic(previousProjection, projection);
|
||||
}
|
||||
|
||||
void _handleSensorSample(WatchSensorSample sample) {
|
||||
final sessionId = sample.sessionId.trim();
|
||||
if (sessionId.isEmpty || sessionId != value.projection.deviceSessionId) {
|
||||
return;
|
||||
}
|
||||
final hasMetric =
|
||||
sample.heartRateBpm != null ||
|
||||
sample.distanceMeters != null ||
|
||||
sample.caloriesKcal != null;
|
||||
if (!hasMetric) {
|
||||
return;
|
||||
}
|
||||
value = value.copyWith(sensorSample: sample);
|
||||
}
|
||||
|
||||
void _handleAck(WatchCommandAckEvent ack) {
|
||||
if (_pendingScoreCommandIds.remove(ack.commandId)) {
|
||||
value = value.copyWith(lastAck: ack);
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
if (_isRejected(ack.status)) {
|
||||
_clearScorePending(recalibrate: true);
|
||||
unawaited(_nativeClient.requestResync());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_pendingCommand?.commandId != ack.commandId) {
|
||||
value = value.copyWith(lastAck: ack);
|
||||
return;
|
||||
@ -208,9 +365,10 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
);
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
if (_isRejected(ack.status)) {
|
||||
final failedType = _pendingCommand?.type;
|
||||
_pendingCommand = null;
|
||||
_clearCommandTimers();
|
||||
value = value.copyWith(commandPending: false);
|
||||
_publishCommandFailure(_commandFailureMessageFor(failedType));
|
||||
unawaited(_nativeClient.requestResync());
|
||||
}
|
||||
}
|
||||
@ -242,12 +400,61 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
_commandTimeoutTimer = null;
|
||||
}
|
||||
|
||||
void _publishCommandFailure(String message) {
|
||||
_commandFailureClearTimer?.cancel();
|
||||
value = value.copyWith(
|
||||
commandPending: false,
|
||||
timerTogglePending: false,
|
||||
commandFailureMessage: message,
|
||||
commandFailureSerial: ++_commandFailureSerial,
|
||||
);
|
||||
_commandFailureClearTimer = Timer(const Duration(seconds: 2), () {
|
||||
value = value.copyWith(clearCommandFailureMessage: true);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearScorePending({required bool recalibrate}) {
|
||||
_pendingScoreCommandIds.clear();
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreWaitingTimer = null;
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer = null;
|
||||
_optimisticManualScoreValue = null;
|
||||
value = value.copyWith(
|
||||
scoreCommandPending: false,
|
||||
scoreWaitingForPhone: false,
|
||||
clearOptimisticManualScoreValue: recalibrate,
|
||||
);
|
||||
}
|
||||
|
||||
void _syncScorePendingFromProjection(WatchSessionProjection projection) {
|
||||
if (_pendingScoreCommandIds.isEmpty) {
|
||||
_optimisticManualScoreValue = null;
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreWaitingTimer = null;
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer = null;
|
||||
return;
|
||||
}
|
||||
final optimistic = _optimisticManualScoreValue;
|
||||
final confirmed = projection.currentManualScoreValue;
|
||||
if (optimistic != null && confirmed == optimistic) {
|
||||
_pendingScoreCommandIds.clear();
|
||||
_optimisticManualScoreValue = null;
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreWaitingTimer = null;
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerProjectionHaptic(
|
||||
WatchSessionProjection previous,
|
||||
WatchSessionProjection current,
|
||||
) {
|
||||
final phaseChanged = previous.phase != current.phase;
|
||||
final enteredReadyTimer = current.phase == WatchSessionPhase.nextTimerReady &&
|
||||
final enteredReadyTimer =
|
||||
current.phase == WatchSessionPhase.nextTimerReady &&
|
||||
previous.phase != WatchSessionPhase.nextTimerReady;
|
||||
final enteredRestEnd =
|
||||
previous.phase == WatchSessionPhase.restRunning &&
|
||||
@ -255,9 +462,11 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
current.phase != WatchSessionPhase.restPaused;
|
||||
if (phaseChanged && (enteredReadyTimer || enteredRestEnd)) {
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
unawaited(Future<void>.delayed(const Duration(milliseconds: 120), () {
|
||||
return HapticFeedback.mediumImpact();
|
||||
}));
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
||||
return HapticFeedback.mediumImpact();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -269,6 +478,28 @@ bool _isRejected(WatchCommandAck ack) {
|
||||
};
|
||||
}
|
||||
|
||||
String _commandFailureMessageFor(WatchCommandType? type) {
|
||||
return switch (type) {
|
||||
WatchCommandType.finishCurrentSet ||
|
||||
WatchCommandType.skipCurrentSet => 'Série non modifiée',
|
||||
_ => 'Commande non appliquée',
|
||||
};
|
||||
}
|
||||
|
||||
bool _isTimerToggleCommand(WatchCommandType type) {
|
||||
return switch (type) {
|
||||
WatchCommandType.startCurrentExercise ||
|
||||
WatchCommandType.startPreparedTimedStep ||
|
||||
WatchCommandType.pauseSession ||
|
||||
WatchCommandType.resumeSession => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _requiresActiveSession(WatchCommandType type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
WatchSessionProjection _initialProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
|
||||
@ -30,6 +30,8 @@ final class WatchCommandAckEvent {
|
||||
abstract interface class NativeWatchBridgeClient {
|
||||
Stream<WatchSessionProjection> get projections;
|
||||
|
||||
Stream<WatchSensorSample> get sensorSamples;
|
||||
|
||||
Stream<WatchCommandAckEvent> get acks;
|
||||
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||
@ -45,25 +47,28 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
implements NativeWatchBridgeClient {
|
||||
const MethodChannelNativeWatchBridgeClient({
|
||||
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||
EventChannel projectionChannel = const EventChannel(
|
||||
_projectionChannelName,
|
||||
EventChannel projectionChannel = const EventChannel(_projectionChannelName),
|
||||
EventChannel sensorSampleChannel = const EventChannel(
|
||||
_sensorSampleChannelName,
|
||||
),
|
||||
EventChannel ackChannel = const EventChannel(_ackChannelName),
|
||||
EventChannel connectionChannel = const EventChannel(
|
||||
_connectionChannelName,
|
||||
),
|
||||
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
|
||||
}) : _methodChannel = methodChannel,
|
||||
_projectionChannel = projectionChannel,
|
||||
_sensorSampleChannel = sensorSampleChannel,
|
||||
_ackChannel = ackChannel,
|
||||
_connectionChannel = connectionChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.watch_bridge/methods';
|
||||
static const _projectionChannelName = 'gametime.watch_bridge/projections';
|
||||
static const _sensorSampleChannelName =
|
||||
'gametime.watch_bridge/sensor_samples';
|
||||
static const _ackChannelName = 'gametime.watch_bridge/acks';
|
||||
static const _connectionChannelName = 'gametime.watch_bridge/connection';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
final EventChannel _projectionChannel;
|
||||
final EventChannel _sensorSampleChannel;
|
||||
final EventChannel _ackChannel;
|
||||
final EventChannel _connectionChannel;
|
||||
|
||||
@ -77,6 +82,16 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples {
|
||||
return _sensorSampleChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) => event is Map)
|
||||
.map((event) {
|
||||
return WatchSensorSample.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchCommandAckEvent> get acks {
|
||||
return _ackChannel
|
||||
@ -144,7 +159,11 @@ String? _nullableStringFromJson(Object? value) {
|
||||
}
|
||||
|
||||
int? _nullableIntFromJson(Object? value) {
|
||||
return value is int ? value : value is num ? value.toInt() : null;
|
||||
return value is int
|
||||
? value
|
||||
: value is num
|
||||
? value.toInt()
|
||||
: null;
|
||||
}
|
||||
|
||||
T _enumFromJson<T extends Enum>(Object? value, List<T> values, T fallback) {
|
||||
|
||||
@ -9,23 +9,41 @@ ThemeData watchTheme() {
|
||||
|
||||
final textTheme = Typography.whiteMountainView.copyWith(
|
||||
labelSmall: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: muted,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
bodySmall: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 11,
|
||||
color: muted,
|
||||
height: 1.15,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
bodyMedium: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 13,
|
||||
color: text,
|
||||
height: 1.15,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
bodySmall: const TextStyle(fontSize: 11, color: muted, height: 1.15),
|
||||
bodyMedium: const TextStyle(fontSize: 13, color: text, height: 1.15),
|
||||
titleSmall: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: text,
|
||||
height: 1.05,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
displayLarge: const TextStyle(
|
||||
fontSize: 44,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: text,
|
||||
fontFamily: 'Anton',
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xFFC9A24A),
|
||||
height: 0.95,
|
||||
letterSpacing: 0,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
);
|
||||
@ -50,8 +68,10 @@ ThemeData watchTheme() {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -62,8 +82,10 @@ ThemeData watchTheme() {
|
||||
side: const BorderSide(color: Color(0xFF303748)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -9,6 +25,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -17,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -30,6 +62,35 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -38,6 +99,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -54,11 +123,67 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -67,6 +192,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
watch_bridge_contract:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -76,3 +209,4 @@ packages:
|
||||
version: "0.1.0"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@ -14,7 +14,16 @@ dependencies:
|
||||
path: ../packages/watch_bridge_contract
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
fonts:
|
||||
- family: Anton
|
||||
fonts:
|
||||
- asset: assets/fonts/Anton-Regular.ttf
|
||||
- family: Archivo
|
||||
fonts:
|
||||
- asset: assets/fonts/Archivo-Variable.ttf
|
||||
|
||||
689
watch_app/test/presentation/watch_session_screen_test.dart
Normal file
@ -0,0 +1,689 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime_watch/application/watch_session_view_model.dart';
|
||||
import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart';
|
||||
import 'package:gametime_watch/presentation/watch_session_screen.dart';
|
||||
import 'package:gametime_watch/presentation/watch_theme.dart';
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'renders an active session on a compact round-sized viewport without overflow',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Squat jump'), findsOneWidget);
|
||||
expect(find.text('02:14'), findsOneWidget);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'transitions from no-session to manual score controls without rendering a black screen',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_noSessionStartProjection());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Aucune séance en cours'), findsOneWidget);
|
||||
|
||||
client.emitProjection(_manualScoreProjection());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Lancers francs'), findsOneWidget);
|
||||
expect(find.text('Routine de tir'), findsOneWidget);
|
||||
expect(find.text('Cible : 8'), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.byTooltip('Ajouter'), findsOneWidget);
|
||||
expect(find.byTooltip('Retirer'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('does not offer a start action from the no-session screen', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_noSessionStartProjection());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Aucune séance en cours'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
|
||||
expect(client.sentCommands, isEmpty);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('starts a stopped exercise timer from the timer button', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_readyProjection());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Routine de tir'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Démarrer'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
client.sentCommands.single.type,
|
||||
WatchCommandType.startCurrentExercise,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('timer-toggle-pending-dot')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('keeps the no-session start action disabled without phone', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
|
||||
client.emitProjection(_noSessionStartProjection(phoneReachable: false));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Téléphone indisponible'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('shows compact timer controls with a manual score step', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_manualScoreProjectionWithTimer());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('Routine de tir'), findsOneWidget);
|
||||
expect(find.text('Chrono étape 02:14'), findsOneWidget);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('shows live heart rate on session and telemetry on stats page', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
expect(find.byTooltip('Stats'), findsNothing);
|
||||
|
||||
client.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
heartRateBpm: 142,
|
||||
distanceMeters: 840,
|
||||
caloriesKcal: 186,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('142 bpm'), findsOneWidget);
|
||||
expect(find.byTooltip('Stats'), findsOneWidget);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('watch-session-page')),
|
||||
const Offset(-220, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('watch-actions-page')),
|
||||
const Offset(-220, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Stats'), findsOneWidget);
|
||||
expect(find.text('FC'), findsOneWidget);
|
||||
expect(find.text('840 m'), findsOneWidget);
|
||||
expect(find.text('186 kcal'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'sends pause command from the icon button when a timer dominates',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Pause'));
|
||||
await tester.pump();
|
||||
|
||||
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows pending feedback inside the pause button', (tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Pause'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('timer-toggle-pending-dot')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'returns to session and shows a discreet notice when a set command is rejected',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Terminer la série'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
client.sentCommands.single.type,
|
||||
WatchCommandType.finishCurrentSet,
|
||||
);
|
||||
expect(find.text('Squat jump'), findsOneWidget);
|
||||
|
||||
client.emitAck(
|
||||
WatchCommandAckEvent(
|
||||
commandId: client.sentCommands.single.commandId,
|
||||
status: WatchCommandAck.rejectedNotApplicable,
|
||||
sessionId: 'session-1',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Série non modifiée'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1900));
|
||||
expect(find.text('Série non modifiée'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps rest skip secondary action open until the session projection updates',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_secondaryRestActionProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Actions'));
|
||||
await tester.pumpAndSettle();
|
||||
_expectActionsPageVisible(tester);
|
||||
|
||||
await tester.tap(find.text('Passer le repos'));
|
||||
await tester.pump();
|
||||
|
||||
expect(client.sentCommands.single.type, WatchCommandType.skipCurrentRest);
|
||||
_expectActionsPageVisible(tester);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
_expectSessionPageVisible(tester);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps rest skip secondary action open and shows feedback when rejected',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_secondaryRestActionProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Passer le repos'));
|
||||
await tester.pump();
|
||||
|
||||
client.emitAck(
|
||||
WatchCommandAckEvent(
|
||||
commandId: client.sentCommands.single.commandId,
|
||||
status: WatchCommandAck.rejectedNotApplicable,
|
||||
sessionId: 'session-1',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
_expectActionsPageVisible(tester);
|
||||
expect(find.text('Commande non appliquée'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
final _projectionController =
|
||||
StreamController<WatchSessionProjection>.broadcast();
|
||||
final _sensorSampleController =
|
||||
StreamController<WatchSensorSample>.broadcast();
|
||||
final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
|
||||
final _connectionController =
|
||||
StreamController<WatchBridgeConnectionEvent>.broadcast();
|
||||
|
||||
var resyncRequests = 0;
|
||||
var capabilityRefreshRequests = 0;
|
||||
final sentCommands = <WatchCommandEnvelope>[];
|
||||
|
||||
@override
|
||||
Stream<WatchSessionProjection> get projections =>
|
||||
_projectionController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples => _sensorSampleController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchCommandAckEvent> get acks => _ackController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
|
||||
_connectionController.stream;
|
||||
|
||||
void emitProjection(WatchSessionProjection projection) {
|
||||
_projectionController.add(projection);
|
||||
}
|
||||
|
||||
void emitSensorSample(WatchSensorSample sample) {
|
||||
_sensorSampleController.add(sample);
|
||||
}
|
||||
|
||||
void emitAck(WatchCommandAckEvent ack) {
|
||||
_ackController.add(ack);
|
||||
}
|
||||
|
||||
void emitConnection(WatchBridgeConnectionEvent event) {
|
||||
_connectionController.add(event);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestCapabilityRefresh() async {
|
||||
capabilityRefreshRequests += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestResync() async {
|
||||
resyncRequests += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> sendCommand(WatchCommandEnvelope command) async {
|
||||
sentCommands.add(command);
|
||||
}
|
||||
}
|
||||
|
||||
WatchSessionProjection _runningProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 1,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 4,
|
||||
exerciseName: 'Squat jump',
|
||||
statusLabel: 'Séance active',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
dominantTimer: _runningStepTimer(),
|
||||
secondaryTimers: const [
|
||||
WatchTimerProjection(
|
||||
kind: WatchTimerKind.setTimer,
|
||||
label: 'Série',
|
||||
displayMode: WatchTimerDisplayMode.elapsed,
|
||||
runState: WatchTimerRunState.running,
|
||||
referenceEpochMs: 0,
|
||||
accumulatedMs: 45000,
|
||||
),
|
||||
],
|
||||
secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
revision: 0,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.noActiveSession,
|
||||
phoneReachable: phoneReachable,
|
||||
seriesIndex: 0,
|
||||
seriesTotal: 0,
|
||||
exerciseName: '',
|
||||
statusLabel: 'Aucune séance',
|
||||
primaryAction: WatchPrimaryAction.none,
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _readyProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 1,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.ready,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Lancers francs',
|
||||
stepName: 'Routine de tir',
|
||||
statusLabel: 'Prêt à démarrer',
|
||||
primaryAction: WatchPrimaryAction.startCurrentExercise,
|
||||
dominantTimer: const WatchTimerProjection(
|
||||
kind: WatchTimerKind.step,
|
||||
label: 'Chrono étape',
|
||||
displayMode: WatchTimerDisplayMode.countdown,
|
||||
runState: WatchTimerRunState.stopped,
|
||||
referenceEpochMs: 0,
|
||||
accumulatedMs: 0,
|
||||
targetMs: 30000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _secondaryRestActionProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 4,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 4,
|
||||
exerciseName: 'Squat jump',
|
||||
statusLabel: 'Séance active',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
secondaryActions: const [WatchSecondaryAction.skipCurrentRest],
|
||||
);
|
||||
}
|
||||
|
||||
void _expectActionsPageVisible(WidgetTester tester) {
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx,
|
||||
lessThan(96),
|
||||
);
|
||||
}
|
||||
|
||||
void _expectSessionPageVisible(WidgetTester tester) {
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('watch-session-page'))).dx,
|
||||
lessThan(96),
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _manualScoreProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 2,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Lancers francs',
|
||||
stepName: 'Routine de tir',
|
||||
statusLabel: 'Score manuel',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
hasManualScore: true,
|
||||
currentManualScoreValue: 3,
|
||||
canDecrementScore: true,
|
||||
manualScoreTargetValue: 8,
|
||||
manualScoreTargetLabel: 'Cible',
|
||||
manualScoreScope: WatchManualScoreScope.series,
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _manualScoreProjectionWithTimer() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 3,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Lancers francs',
|
||||
stepName: 'Routine de tir',
|
||||
statusLabel: 'Score manuel',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
dominantTimer: _runningStepTimer(),
|
||||
hasManualScore: true,
|
||||
currentManualScoreValue: 3,
|
||||
canDecrementScore: true,
|
||||
manualScoreTargetValue: 8,
|
||||
manualScoreTargetLabel: 'Cible',
|
||||
manualScoreScope: WatchManualScoreScope.step,
|
||||
);
|
||||
}
|
||||
|
||||
WatchTimerProjection _runningStepTimer() {
|
||||
return WatchTimerProjection(
|
||||
kind: WatchTimerKind.step,
|
||||
label: 'Chrono étape',
|
||||
displayMode: WatchTimerDisplayMode.elapsed,
|
||||
runState: WatchTimerRunState.running,
|
||||
referenceEpochMs: DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(minutes: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
accumulatedMs: 134000,
|
||||
startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||
targetMs: 180000,
|
||||
);
|
||||
}
|
||||