fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)
This commit is contained in:
@ -7,6 +7,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BODY_SENSORS"
|
||||
android:maxSdkVersion="35" />
|
||||
@ -36,7 +37,7 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
@ -47,6 +48,10 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.gametime.watch.OPEN_ACTIVE_SESSION" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.gametime.watch
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.wear.ambient.AmbientModeSupport
|
||||
import com.gametime.watch.bridge.WatchBridgePlugin
|
||||
@ -13,6 +14,18 @@ class MainActivity :
|
||||
super.onCreate(savedInstanceState)
|
||||
AmbientModeSupport.attach(this)
|
||||
WatchBridgePlugin.attachActivity(this)
|
||||
WatchBridgePlugin.handleActivityReentry(this, intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
WatchBridgePlugin.handleActivityReentry(this, intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
WatchBridgePlugin.handleActivityReentry(this, intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@ -34,6 +34,7 @@ object WatchBridgePlugin {
|
||||
const val ACK_PATH = "/gametime/phone/ack"
|
||||
const val STATE_PATH = "/gametime/phone/projection"
|
||||
const val PHONE_CAPABILITY = "gametime_phone_companion"
|
||||
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION"
|
||||
private const val SENSOR_PERMISSION_REQUEST = 4106
|
||||
private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L
|
||||
private const val READ_HEART_RATE_PERMISSION =
|
||||
@ -56,6 +57,9 @@ object WatchBridgePlugin {
|
||||
private var lastSensorPermissionRequestEpochMs = 0L
|
||||
private var pendingSensorPermissionRequest = false
|
||||
private var lastSensorProjection: Map<String, Any?>? = null
|
||||
private var lastActiveProjection: Map<String, Any?>? = null
|
||||
private var lastActiveProjectionReceivedAtEpochMs = 0L
|
||||
private var activeProjectionExpiryRunnable: Runnable? = null
|
||||
|
||||
fun attachApplicationContext(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
@ -123,6 +127,16 @@ object WatchBridgePlugin {
|
||||
requestPendingSensorPermissionIfPossible()
|
||||
}
|
||||
|
||||
fun handleActivityReentry(activity: Activity, intent: android.content.Intent?) {
|
||||
attachActivity(activity)
|
||||
val context = activity.applicationContext
|
||||
requestCapabilityRefresh(context)
|
||||
requestLatestProjection(context)
|
||||
if (intent?.action == ACTION_OPEN_ACTIVE_SESSION && hasFreshActiveProjection()) {
|
||||
emitProjection(lastActiveProjection ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
fun detachActivity(activity: Activity) {
|
||||
if (this.activity === activity) {
|
||||
this.activity = null
|
||||
@ -159,7 +173,9 @@ object WatchBridgePlugin {
|
||||
}
|
||||
|
||||
fun emitProjection(payload: Map<String, Any?>): Boolean {
|
||||
rememberActiveProjection(payload)
|
||||
appContext?.let {
|
||||
scheduleActiveProjectionExpiry(it, payload)
|
||||
WatchOngoingActivityController.update(it, payload, activity)
|
||||
updateHeartRateCollection(it, payload)
|
||||
}
|
||||
@ -229,10 +245,24 @@ object WatchBridgePlugin {
|
||||
requestCapabilityRefresh(context)
|
||||
result.success(null)
|
||||
}
|
||||
"invalidateActiveProjection" -> {
|
||||
invalidateActiveProjection(context)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateActiveProjection(context: Context) {
|
||||
lastActiveProjection = null
|
||||
lastActiveProjectionReceivedAtEpochMs = 0L
|
||||
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
activeProjectionExpiryRunnable = null
|
||||
WatchOngoingActivityController.cancel(context)
|
||||
WatchHeartRateForegroundService.stop(context)
|
||||
heartRateCollector.finishCurrentSession(context)
|
||||
}
|
||||
|
||||
private fun sendCommand(
|
||||
context: Context,
|
||||
arguments: Any?,
|
||||
@ -318,6 +348,63 @@ object WatchBridgePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fun openActiveSessionIntent(context: Context): android.content.Intent {
|
||||
return android.content.Intent(context, com.gametime.watch.MainActivity::class.java).apply {
|
||||
action = ACTION_OPEN_ACTIVE_SESSION
|
||||
flags = android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberActiveProjection(projection: Map<String, Any?>) {
|
||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||
lastActiveProjection = null
|
||||
lastActiveProjectionReceivedAtEpochMs = 0L
|
||||
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
activeProjectionExpiryRunnable = null
|
||||
return
|
||||
}
|
||||
lastActiveProjection = projection
|
||||
lastActiveProjectionReceivedAtEpochMs = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun scheduleActiveProjectionExpiry(context: Context, projection: Map<String, Any?>) {
|
||||
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||
activeProjectionExpiryRunnable = null
|
||||
return
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
|
||||
val delayMs = if (expiresAt > 0L) {
|
||||
(expiresAt - now).coerceAtLeast(0L)
|
||||
} else {
|
||||
12000L
|
||||
}
|
||||
val appContext = context.applicationContext
|
||||
activeProjectionExpiryRunnable = Runnable {
|
||||
if (!hasFreshActiveProjection()) {
|
||||
invalidateActiveProjection(appContext)
|
||||
}
|
||||
}.also { runnable ->
|
||||
mainHandler.postDelayed(runnable, delayMs + 250L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasFreshActiveProjection(): Boolean {
|
||||
val projection = lastActiveProjection ?: return false
|
||||
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
|
||||
val now = System.currentTimeMillis()
|
||||
if (expiresAt > 0L) {
|
||||
return now < expiresAt
|
||||
}
|
||||
return now - lastActiveProjectionReceivedAtEpochMs <= 12000L
|
||||
}
|
||||
|
||||
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
|
||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||
@ -416,6 +503,7 @@ object WatchBridgePlugin {
|
||||
return listOf(
|
||||
heartRatePermission,
|
||||
android.Manifest.permission.ACTIVITY_RECOGNITION,
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ package com.gametime.watch.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.health.services.client.ExerciseClient
|
||||
import androidx.health.services.client.ExerciseUpdateCallback
|
||||
import androidx.health.services.client.HealthServices
|
||||
import androidx.health.services.client.MeasureClient
|
||||
import androidx.health.services.client.MeasureCallback
|
||||
@ -9,6 +11,10 @@ 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 androidx.health.services.client.data.ExerciseConfig
|
||||
import androidx.health.services.client.data.ExerciseEvent
|
||||
import androidx.health.services.client.data.ExerciseLapSummary
|
||||
import androidx.health.services.client.data.ExerciseType
|
||||
import com.google.android.gms.wearable.CapabilityClient
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
import org.json.JSONObject
|
||||
@ -35,10 +41,12 @@ internal class WatchHeartRateCollector(
|
||||
private var sampleSequence = 0
|
||||
private var executionContext: Map<String, Any?> = emptyMap()
|
||||
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
|
||||
private var exerciseMetricsStarted = false
|
||||
private var exerciseMetricsStartInFlight = false
|
||||
private var shouldAggregate = false
|
||||
private var appContext: Context? = null
|
||||
|
||||
private val callback = object : MeasureCallback {
|
||||
private val measureCallback = object : MeasureCallback {
|
||||
override fun onAvailabilityChanged(
|
||||
dataType: DeltaDataType<*, *>,
|
||||
availability: Availability,
|
||||
@ -54,21 +62,11 @@ internal class WatchHeartRateCollector(
|
||||
for (point in data.getData(DataType.HEART_RATE_BPM)) {
|
||||
latestHeartRateBpm = recordHeartRate(point.value)
|
||||
}
|
||||
var updatedDistance = false
|
||||
for (point in data.getData(DataType.DISTANCE)) {
|
||||
if (point.value > 0) {
|
||||
distanceMeters = (distanceMeters ?: 0.0) + point.value
|
||||
updatedDistance = true
|
||||
}
|
||||
}
|
||||
var updatedCalories = false
|
||||
for (point in data.getData(DataType.CALORIES)) {
|
||||
if (point.value > 0) {
|
||||
caloriesKcal = (caloriesKcal ?: 0.0) + point.value
|
||||
updatedCalories = true
|
||||
}
|
||||
}
|
||||
if (latestHeartRateBpm != null || updatedDistance || updatedCalories) {
|
||||
if (latestHeartRateBpm != null) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
|
||||
)
|
||||
sendSample(latestHeartRateBpm)
|
||||
}
|
||||
}
|
||||
@ -78,6 +76,57 @@ internal class WatchHeartRateCollector(
|
||||
}
|
||||
}
|
||||
|
||||
private val exerciseCallback = object : ExerciseUpdateCallback {
|
||||
override fun onRegistered() {
|
||||
Log.d(TAG, "exercise update callback registered sessionId=$sessionId")
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(throwable: Throwable) {
|
||||
Log.w(TAG, "exercise update callback registration failed", throwable)
|
||||
}
|
||||
|
||||
override fun onExerciseUpdateReceived(update: androidx.health.services.client.data.ExerciseUpdate) {
|
||||
if (!shouldAggregate) {
|
||||
return
|
||||
}
|
||||
var updated = false
|
||||
for (point in update.latestMetrics.getData(DataType.DISTANCE)) {
|
||||
val value = point.value
|
||||
if (value > 0) {
|
||||
distanceMeters = (distanceMeters ?: 0.0) + value
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
for (point in update.latestMetrics.getData(DataType.CALORIES)) {
|
||||
val value = point.value
|
||||
if (value > 0) {
|
||||
caloriesKcal = (caloriesKcal ?: 0.0) + value
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"exercise metrics received sessionId=$sessionId distance=$distanceMeters calories=$caloriesKcal",
|
||||
)
|
||||
sendSample(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLapSummaryReceived(lapSummary: ExerciseLapSummary) {}
|
||||
|
||||
override fun onAvailabilityChanged(
|
||||
dataType: androidx.health.services.client.data.DataType<*, *>,
|
||||
availability: Availability,
|
||||
) {
|
||||
Log.d(TAG, "exercise availability dataType=$dataType availability=$availability")
|
||||
}
|
||||
|
||||
override fun onExerciseEventReceived(event: ExerciseEvent) {
|
||||
Log.d(TAG, "exercise event sessionId=$sessionId event=$event")
|
||||
}
|
||||
}
|
||||
|
||||
fun noteActiveSession(
|
||||
nextSessionId: String,
|
||||
shouldAggregate: Boolean,
|
||||
@ -103,17 +152,18 @@ internal class WatchHeartRateCollector(
|
||||
appContext = context.applicationContext
|
||||
val measureClient = HealthServices.getClient(context).measureClient
|
||||
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
|
||||
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
|
||||
registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES)
|
||||
startExerciseMetrics(context)
|
||||
}
|
||||
|
||||
fun pause(context: Context) {
|
||||
shouldAggregate = false
|
||||
unregister(context)
|
||||
stopExerciseMetrics(context)
|
||||
}
|
||||
|
||||
fun finishCurrentSession(context: Context) {
|
||||
unregister(context)
|
||||
stopExerciseMetrics(context)
|
||||
val completedSessionId = sessionId
|
||||
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
|
||||
sendSummary(context, completedSessionId)
|
||||
@ -188,6 +238,8 @@ internal class WatchHeartRateCollector(
|
||||
"minHeartRateBpm" to min,
|
||||
"averageHeartRateBpm" to sampleSum / sampleCount,
|
||||
"maxHeartRateBpm" to max,
|
||||
"distanceMeters" to distanceMeters,
|
||||
"caloriesKcal" to caloriesKcal,
|
||||
),
|
||||
).toString().toByteArray(StandardCharsets.UTF_8)
|
||||
Wearable.getCapabilityClient(context)
|
||||
@ -213,10 +265,9 @@ internal class WatchHeartRateCollector(
|
||||
}
|
||||
val measureClient = HealthServices.getClient(context).measureClient
|
||||
for (dataType in registeredDataTypes.toList()) {
|
||||
measureClient.unregisterMeasureCallbackAsync(dataType, callback)
|
||||
measureClient.unregisterMeasureCallbackAsync(dataType, measureCallback)
|
||||
}
|
||||
registeredDataTypes.clear()
|
||||
appContext = null
|
||||
}
|
||||
|
||||
private fun registerMeasureCallbackIfNeeded(
|
||||
@ -227,7 +278,7 @@ internal class WatchHeartRateCollector(
|
||||
return
|
||||
}
|
||||
try {
|
||||
measureClient.registerMeasureCallback(dataType, callback)
|
||||
measureClient.registerMeasureCallback(dataType, measureCallback)
|
||||
registeredDataTypes.add(dataType)
|
||||
Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId")
|
||||
} catch (error: RuntimeException) {
|
||||
@ -235,6 +286,108 @@ internal class WatchHeartRateCollector(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startExerciseMetrics(context: Context) {
|
||||
if (exerciseMetricsStarted || exerciseMetricsStartInFlight) {
|
||||
return
|
||||
}
|
||||
val exerciseClient = HealthServices.getClient(context).exerciseClient
|
||||
exerciseMetricsStartInFlight = true
|
||||
val capabilitiesFuture = exerciseClient.getCapabilitiesAsync()
|
||||
capabilitiesFuture.addListener(
|
||||
{
|
||||
try {
|
||||
val capabilities = capabilitiesFuture.get()
|
||||
val config = exerciseConfigFromCapabilities(capabilities)
|
||||
if (config == null) {
|
||||
exerciseMetricsStartInFlight = false
|
||||
Log.w(TAG, "no exercise type supports distance metrics sessionId=$sessionId")
|
||||
return@addListener
|
||||
}
|
||||
exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback)
|
||||
val startFuture = exerciseClient.startExerciseAsync(config)
|
||||
startFuture.addListener(
|
||||
{
|
||||
exerciseMetricsStartInFlight = false
|
||||
try {
|
||||
startFuture.get()
|
||||
exerciseMetricsStarted = true
|
||||
Log.d(
|
||||
TAG,
|
||||
"exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}",
|
||||
)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "exercise metrics start failed", error)
|
||||
clearExerciseCallback(exerciseClient)
|
||||
}
|
||||
},
|
||||
context.mainExecutor,
|
||||
)
|
||||
} catch (error: Exception) {
|
||||
exerciseMetricsStartInFlight = false
|
||||
Log.w(TAG, "exercise capabilities lookup failed", error)
|
||||
}
|
||||
},
|
||||
context.mainExecutor,
|
||||
)
|
||||
}
|
||||
|
||||
private fun exerciseConfigFromCapabilities(
|
||||
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
|
||||
): ExerciseConfig? {
|
||||
val requestedTypes = listOf(
|
||||
ExerciseType.WORKOUT,
|
||||
ExerciseType.RUNNING,
|
||||
ExerciseType.WALKING,
|
||||
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
|
||||
)
|
||||
for (exerciseType in requestedTypes) {
|
||||
if (exerciseType !in capabilities.supportedExerciseTypes) {
|
||||
continue
|
||||
}
|
||||
val supported = capabilities.getExerciseTypeCapabilities(exerciseType)
|
||||
.supportedDataTypes
|
||||
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
|
||||
if (DataType.DISTANCE !in supported) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"exercise type lacks distance type=$exerciseType supported=$supported",
|
||||
)
|
||||
continue
|
||||
}
|
||||
dataTypes.add(DataType.DISTANCE)
|
||||
if (DataType.CALORIES in supported) {
|
||||
dataTypes.add(DataType.CALORIES)
|
||||
}
|
||||
return ExerciseConfig.builder(exerciseType)
|
||||
.setDataTypes(dataTypes)
|
||||
.setIsAutoPauseAndResumeEnabled(false)
|
||||
.setIsGpsEnabled(true)
|
||||
.build()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun stopExerciseMetrics(context: Context) {
|
||||
if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) {
|
||||
return
|
||||
}
|
||||
val exerciseClient = HealthServices.getClient(context).exerciseClient
|
||||
clearExerciseCallback(exerciseClient)
|
||||
if (exerciseMetricsStarted) {
|
||||
exerciseClient.endExerciseAsync()
|
||||
}
|
||||
exerciseMetricsStarted = false
|
||||
exerciseMetricsStartInFlight = false
|
||||
}
|
||||
|
||||
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
|
||||
try {
|
||||
exerciseClient.clearUpdateCallbackAsync(exerciseCallback)
|
||||
} catch (error: RuntimeException) {
|
||||
Log.w(TAG, "clear exercise update callback failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset(nextSessionId: String?) {
|
||||
sessionId = nextSessionId
|
||||
sampleCount = 0
|
||||
|
||||
@ -13,7 +13,6 @@ import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.gametime.watch.MainActivity
|
||||
import com.gametime.watch.R
|
||||
|
||||
internal class WatchHeartRateForegroundService : Service() {
|
||||
@ -71,9 +70,7 @@ internal class WatchHeartRateForegroundService : Service() {
|
||||
val touchIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
},
|
||||
WatchBridgePlugin.openActiveSessionIntent(this),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
|
||||
@ -6,14 +6,12 @@ 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 {
|
||||
@ -47,9 +45,7 @@ object WatchOngoingActivityController {
|
||||
val touchIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
},
|
||||
WatchBridgePlugin.openActiveSessionIntent(context),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val exerciseName = (projection["exerciseName"] as? String)
|
||||
|
||||
@ -128,6 +128,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
Timer? _scoreWaitingTimer;
|
||||
Timer? _scoreCommandTimeoutTimer;
|
||||
Timer? _freshnessTimer;
|
||||
Timer? _projectionExpiryTimer;
|
||||
Timer? _commandFailureClearTimer;
|
||||
WatchCommandEnvelope? _pendingCommand;
|
||||
final _pendingScoreCommandIds = <String>{};
|
||||
@ -198,6 +199,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_freshnessTimer?.cancel();
|
||||
_projectionExpiryTimer?.cancel();
|
||||
_commandFailureClearTimer?.cancel();
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
@ -309,6 +311,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
void _handleProjection(WatchSessionProjection projection) {
|
||||
final previousProjection = value.projection;
|
||||
_lastProjectionReceivedAt = DateTime.now();
|
||||
_scheduleProjectionExpiry(projection);
|
||||
_pendingCommand = null;
|
||||
_clearCommandTimers();
|
||||
_syncScorePendingFromProjection(projection);
|
||||
@ -388,7 +391,23 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
if (receivedAt == null) {
|
||||
return;
|
||||
}
|
||||
final age = DateTime.now().difference(receivedAt);
|
||||
final now = DateTime.now();
|
||||
final expiresAtEpochMs = value.projection.expiresAtEpochMs;
|
||||
final fallbackExpired =
|
||||
expiresAtEpochMs <= 0 &&
|
||||
now.difference(receivedAt) >= const Duration(seconds: 12);
|
||||
final expired =
|
||||
value.projection.deviceSessionId.isNotEmpty &&
|
||||
(fallbackExpired ||
|
||||
(expiresAtEpochMs > 0 &&
|
||||
now.toUtc().millisecondsSinceEpoch >= expiresAtEpochMs)) &&
|
||||
_pendingCommand == null &&
|
||||
_pendingScoreCommandIds.isEmpty;
|
||||
if (expired) {
|
||||
_invalidateExpiredProjection();
|
||||
return;
|
||||
}
|
||||
final age = now.difference(receivedAt);
|
||||
final stale = age >= _staleProjectionThreshold;
|
||||
final lost = age >= _connectionLostThreshold;
|
||||
if (stale != value.staleProjection || lost != value.connectionLost) {
|
||||
@ -396,6 +415,53 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _invalidateExpiredProjection() {
|
||||
_projectionExpiryTimer?.cancel();
|
||||
_projectionExpiryTimer = null;
|
||||
_pendingCommand = null;
|
||||
_pendingScoreCommandIds.clear();
|
||||
_optimisticManualScoreValue = null;
|
||||
_clearCommandTimers();
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreWaitingTimer = null;
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer = null;
|
||||
_lastProjectionReceivedAt = null;
|
||||
value = WatchSessionUiState(
|
||||
projection: _expiredProjection(),
|
||||
connectionLost: true,
|
||||
staleProjection: true,
|
||||
commandFailureMessage: value.commandFailureMessage,
|
||||
commandFailureSerial: value.commandFailureSerial,
|
||||
lastAck: value.lastAck,
|
||||
);
|
||||
unawaited(_nativeClient.invalidateActiveProjection());
|
||||
}
|
||||
|
||||
void _scheduleProjectionExpiry(WatchSessionProjection projection) {
|
||||
_projectionExpiryTimer?.cancel();
|
||||
_projectionExpiryTimer = null;
|
||||
if (projection.deviceSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
final expiresAtEpochMs = projection.expiresAtEpochMs > 0
|
||||
? projection.expiresAtEpochMs
|
||||
: nowMs + const Duration(seconds: 12).inMilliseconds;
|
||||
final delayMs = expiresAtEpochMs - nowMs;
|
||||
_projectionExpiryTimer = Timer(
|
||||
Duration(milliseconds: delayMs <= 0 ? 0 : delayMs),
|
||||
() {
|
||||
if (value.projection.deviceSessionId.isEmpty ||
|
||||
_pendingCommand != null ||
|
||||
_pendingScoreCommandIds.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
_invalidateExpiredProjection();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _clearCommandTimers() {
|
||||
_waitingTimer?.cancel();
|
||||
_waitingTimer = null;
|
||||
@ -504,10 +570,29 @@ bool _requiresActiveSession(WatchCommandType type) {
|
||||
}
|
||||
|
||||
WatchSessionProjection _initialProjection() {
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
revision: 0,
|
||||
projectedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||
projectedAtEpochMs: nowMs,
|
||||
expiresAtEpochMs: nowMs,
|
||||
phase: WatchSessionPhase.noActiveSession,
|
||||
phoneReachable: false,
|
||||
seriesIndex: 0,
|
||||
seriesTotal: 0,
|
||||
exerciseName: '',
|
||||
primaryAction: WatchPrimaryAction.none,
|
||||
statusLabel: 'Téléphone indisponible',
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _expiredProjection() {
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
revision: 0,
|
||||
projectedAtEpochMs: nowMs,
|
||||
expiresAtEpochMs: nowMs,
|
||||
phase: WatchSessionPhase.noActiveSession,
|
||||
phoneReachable: false,
|
||||
seriesIndex: 0,
|
||||
|
||||
@ -41,6 +41,8 @@ abstract interface class NativeWatchBridgeClient {
|
||||
Future<void> requestResync();
|
||||
|
||||
Future<void> requestCapabilityRefresh();
|
||||
|
||||
Future<void> invalidateActiveProjection();
|
||||
}
|
||||
|
||||
final class MethodChannelNativeWatchBridgeClient
|
||||
@ -141,6 +143,11 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
Future<void> requestResync() {
|
||||
return _methodChannel.invokeMethod<void>('requestResync');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> invalidateActiveProjection() {
|
||||
return _methodChannel.invokeMethod<void>('invalidateActiveProjection');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _stringObjectMap(Object? value) {
|
||||
|
||||
@ -99,7 +99,11 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return PageView(controller: _pageController, children: pages);
|
||||
return PageView(
|
||||
controller: _pageController,
|
||||
physics: const _WatchPageScrollPhysics(),
|
||||
children: pages,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -220,7 +224,21 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
||||
!_completionHapticTimerKeys.add(key)) {
|
||||
return;
|
||||
}
|
||||
_triggerTimerCompletionHaptic();
|
||||
}
|
||||
|
||||
void _triggerTimerCompletionHaptic() {
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(milliseconds: 140), () {
|
||||
return HapticFeedback.heavyImpact();
|
||||
}),
|
||||
);
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(milliseconds: 320), () {
|
||||
return HapticFeedback.heavyImpact();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirm({
|
||||
@ -242,6 +260,47 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
final class _WatchPageScrollPhysics extends PageScrollPhysics {
|
||||
const _WatchPageScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
_WatchPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return _WatchPageScrollPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(
|
||||
ScrollMetrics position,
|
||||
double velocity,
|
||||
) {
|
||||
if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
|
||||
(velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
|
||||
return super.createBallisticSimulation(position, velocity);
|
||||
}
|
||||
final viewport = position is PageMetrics
|
||||
? position.viewportDimension * position.viewportFraction
|
||||
: position.viewportDimension;
|
||||
if (viewport <= 0) {
|
||||
return null;
|
||||
}
|
||||
final target = (position.pixels / viewport).roundToDouble() * viewport;
|
||||
final clampedTarget = target.clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
if (clampedTarget == position.pixels) {
|
||||
return null;
|
||||
}
|
||||
return ScrollSpringSimulation(
|
||||
spring,
|
||||
position.pixels,
|
||||
clampedTarget,
|
||||
velocity,
|
||||
tolerance: toleranceFor(position),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _RoundScaffold extends StatelessWidget {
|
||||
const _RoundScaffold({required this.child, this.notice, super.key});
|
||||
|
||||
@ -927,17 +986,21 @@ final class _ManualScoreContent extends StatelessWidget {
|
||||
);
|
||||
final target = projection.manualScoreTargetValue;
|
||||
final targetLabel = projection.manualScoreTargetLabel;
|
||||
final captionSegments = [
|
||||
if (projection.manualScoreRepsTargetValue != null)
|
||||
'Répétitions : ${projection.manualScoreRepsTargetValue}',
|
||||
if (target != null && targetLabel != null && targetLabel.isNotEmpty)
|
||||
'$targetLabel : ${_scoreText(target)}',
|
||||
];
|
||||
return _ScaledContent(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ExerciseName(projection.exerciseName),
|
||||
_StepNameBand(projection.stepName),
|
||||
if (target != null &&
|
||||
targetLabel != null &&
|
||||
targetLabel.isNotEmpty) ...[
|
||||
if (captionSegments.isNotEmpty) ...[
|
||||
Text(
|
||||
'$targetLabel : ${_scoreText(target)}',
|
||||
captionSegments.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@ -87,6 +87,36 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows reps target on step manual score content', (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(_manualScoreProjectionWithRepsTarget());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Répétitions : 10 · Cible : 8'), findsOneWidget);
|
||||
expect(find.text('SCORE'), findsOneWidget);
|
||||
expect(find.byTooltip('Ajouter'), findsOneWidget);
|
||||
expect(find.byTooltip('Valider l’étape'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('hides set timer even when it is projected as dominant', (
|
||||
tester,
|
||||
) async {
|
||||
@ -683,22 +713,63 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('vibrates once when a countdown timer reaches zero', (
|
||||
testWidgets(
|
||||
'uses a strong pulse sequence when a countdown timer reaches zero',
|
||||
(tester) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
hapticCalls.add(call);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
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(_countdownProjection(accumulatedMs: 29000));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, isEmpty);
|
||||
|
||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(hapticCalls, hasLength(3));
|
||||
expect(
|
||||
hapticCalls.map((call) => call.arguments),
|
||||
everyElement('HapticFeedbackType.heavyImpact'),
|
||||
);
|
||||
|
||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(3));
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('expires an orphaned active projection after its TTL', (
|
||||
tester,
|
||||
) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
hapticCalls.add(call);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
@ -714,20 +785,18 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_countdownProjection(accumulatedMs: 29000));
|
||||
client.emitProjection(
|
||||
_expiringProjection(expiresIn: const Duration(seconds: 1)),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(hapticCalls, isEmpty);
|
||||
expect(find.text('Squat jump'), findsOneWidget);
|
||||
|
||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact');
|
||||
|
||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(viewModel.value.projection.phase, WatchSessionPhase.noActiveSession);
|
||||
expect(viewModel.value.connectionLost, isTrue);
|
||||
expect(client.invalidatedProjectionCount, 1);
|
||||
expect(find.text('Téléphone indisponible'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
@ -745,6 +814,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
|
||||
var resyncRequests = 0;
|
||||
var capabilityRefreshRequests = 0;
|
||||
var invalidatedProjectionCount = 0;
|
||||
final sentCommands = <WatchCommandEnvelope>[];
|
||||
|
||||
@override
|
||||
@ -782,6 +852,11 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
capabilityRefreshRequests += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> invalidateActiveProjection() async {
|
||||
invalidatedProjectionCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestResync() async {
|
||||
resyncRequests += 1;
|
||||
@ -794,10 +869,12 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
}
|
||||
|
||||
WatchSessionProjection _runningProjection() {
|
||||
final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 1,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
projectedAtEpochMs: projectedAt,
|
||||
expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
@ -820,6 +897,23 @@ WatchSessionProjection _runningProjection() {
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _expiringProjection({required Duration expiresIn}) {
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 99,
|
||||
projectedAtEpochMs: nowMs,
|
||||
expiresAtEpochMs: nowMs + expiresIn.inMilliseconds,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Squat jump',
|
||||
statusLabel: 'Chrono étape',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
@ -980,6 +1074,29 @@ WatchSessionProjection _manualScoreProjectionWithTimer() {
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _manualScoreProjectionWithRepsTarget() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 4,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Pompes tempo',
|
||||
stepName: 'Score libre',
|
||||
statusLabel: 'Score manuel',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
hasManualScore: true,
|
||||
currentManualScoreValue: 3,
|
||||
canDecrementScore: true,
|
||||
manualScoreTargetValue: 8,
|
||||
manualScoreTargetLabel: 'Cible',
|
||||
manualScoreRepsTargetValue: 10,
|
||||
manualScoreScope: WatchManualScoreScope.step,
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _restProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
|
||||
Reference in New Issue
Block a user