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

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

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

View File

@ -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")
}

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

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

View File

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

View File

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

View File

@ -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 {

View 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

Binary file not shown.

Binary file not shown.

View 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: '',

View File

@ -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) {

File diff suppressed because it is too large Load Diff

View File

@ -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,
),
),
),

View File

@ -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"

View File

@ -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

View 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,
);
}