diff --git a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt index 8e4493f..c5d7c7d 100644 --- a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt +++ b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt @@ -169,20 +169,27 @@ object WatchBridgePlugin { result.error("invalid_projection", "Projection payload must be a map.", null) return } - val projectionJson = JSONObject(map).toString() + val projection = map.filterKeys { it != "urgent" } + val projectionJson = JSONObject(projection).toString() val request = PutDataMapRequest.create(STATE_PATH).apply { dataMap.putString("projectionJson", projectionJson) - dataMap.putInt("schemaVersion", (map["schemaVersion"] as? Number)?.toInt() ?: 1) - dataMap.putInt("revision", (map["revision"] as? Number)?.toInt() ?: 0) + dataMap.putInt( + "schemaVersion", + (projection["schemaVersion"] as? Number)?.toInt() ?: 1, + ) + dataMap.putInt("revision", (projection["revision"] as? Number)?.toInt() ?: 0) dataMap.putLong( "projectedAtEpochMs", - (map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L, + (projection["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L, ) dataMap.putLong( "expiresAtEpochMs", - (map["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L, + (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L, ) - }.asPutDataRequest().setUrgent() + }.asPutDataRequest() + if (map["urgent"] == true) { + request.setUrgent() + } Wearable.getDataClient(context).putDataItem(request) .addOnSuccessListener { result.success(null) } .addOnFailureListener { error -> diff --git a/lib/application/watch_companion_use_cases.dart b/lib/application/watch_companion_use_cases.dart index e9a032a..e930f10 100644 --- a/lib/application/watch_companion_use_cases.dart +++ b/lib/application/watch_companion_use_cases.dart @@ -5,7 +5,7 @@ abstract interface class WatchCommandIngress { } abstract interface class WatchProjectionPublisher { - Future publish(WatchSessionProjection projection); + Future publish(WatchSessionProjection projection, {bool urgent = true}); } abstract interface class WatchAlertPublisher { diff --git a/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart b/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart index 738ae7f..6038ad5 100644 --- a/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart +++ b/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart @@ -22,7 +22,10 @@ abstract interface class WatchBridgeNativeChannel { Stream get connectionEvents; - Future publishProjection(WatchSessionProjection projection); + Future publishProjection( + WatchSessionProjection projection, { + bool urgent = true, + }); Future publishAlert(WatchAlertEnvelope alert); @@ -124,11 +127,14 @@ final class MethodChannelWatchBridgeNativeChannel } @override - Future publishProjection(WatchSessionProjection projection) { - return _invokeIgnoringMissingPlugin( - 'publishProjection', - projection.toJson(), - ); + Future publishProjection( + WatchSessionProjection projection, { + bool urgent = true, + }) { + return _invokeIgnoringMissingPlugin('publishProjection', { + ...projection.toJson(), + 'urgent': urgent, + }); } @override diff --git a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart index 3a1dca1..33317b2 100644 --- a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart +++ b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart @@ -15,7 +15,7 @@ final class WatchWearDataLayerAdapter WorkoutHistoryUseCases? workoutHistoryUseCases, ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases, WorkoutTelemetryUseCases? workoutTelemetryUseCases, - Duration projectionRefreshInterval = const Duration(seconds: 2), + Duration projectionRefreshInterval = const Duration(seconds: 5), }) : _nativeChannel = nativeChannel, _commandIngress = commandIngress, _projectionSource = projectionSource, @@ -36,6 +36,9 @@ final class WatchWearDataLayerAdapter Future _commandTail = Future.value(); Timer? _projectionRefreshTimer; WatchSessionProjection? _latestProjection; + WatchSessionProjection? _lastPublishedProjection; + bool _skipNextProjectionEmissionForForcedResync = false; + int? _lastPublishedProjectionRevision; bool _started = false; bool _foregroundActive = false; @@ -47,6 +50,10 @@ final class WatchWearDataLayerAdapter _ensureProjectionRefreshLoop(); _subscriptions.add( _projectionSource.projections.listen((projection) { + if (_skipNextProjectionEmissionForForcedResync) { + _skipNextProjectionEmissionForForcedResync = false; + return; + } unawaited(publish(projection)); }), ); @@ -77,7 +84,7 @@ final class WatchWearDataLayerAdapter _subscriptions.add( _nativeChannel.connectionEvents.listen((event) { if (event.isReachable || event.requestsResync) { - unawaited(_projectionSource.emitCurrentProjection()); + unawaited(_forceProjectionResync()); } }), ); @@ -96,7 +103,18 @@ final class WatchWearDataLayerAdapter } @override - Future publish(WatchSessionProjection projection) async { + Future publish( + WatchSessionProjection projection, { + bool urgent = true, + }) async { + await _publishProjection(projection, urgent: urgent, force: false); + } + + Future _publishProjection( + WatchSessionProjection projection, { + required bool urgent, + required bool force, + }) async { final previousProjection = _latestProjection; _latestProjection = projection; if (projection.phase == WatchSessionPhase.noActiveSession) { @@ -105,7 +123,23 @@ final class WatchWearDataLayerAdapter _activeWorkoutSensorUseCases?.clear(previousSessionId); } } - await _nativeChannel.publishProjection(projection); + if (!force && + _lastPublishedProjection != null && + _hasSameSignificantProjectionState( + _lastPublishedProjection!, + projection, + )) { + await _syncForegroundService(projection); + return; + } + final revisionChanged = + _lastPublishedProjectionRevision != projection.revision; + await _nativeChannel.publishProjection( + projection, + urgent: force ? urgent : urgent && revisionChanged, + ); + _lastPublishedProjection = projection; + _lastPublishedProjectionRevision = projection.revision; await _syncForegroundService(projection); } @@ -180,9 +214,114 @@ final class WatchWearDataLayerAdapter void _ensureProjectionRefreshLoop() { _projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) { - unawaited(_projectionSource.emitCurrentProjection()); + unawaited(_publishHeartbeat()); }); } + + Future _publishHeartbeat() async { + final projection = await _emitCurrentProjectionSkippingSourceEcho(); + await _publishProjection(projection, urgent: false, force: true); + } + + Future _forceProjectionResync() async { + final projection = await _emitCurrentProjectionSkippingSourceEcho(); + await _publishProjection(projection, urgent: true, force: true); + } + + Future + _emitCurrentProjectionSkippingSourceEcho() async { + _skipNextProjectionEmissionForForcedResync = true; + try { + return await _projectionSource.emitCurrentProjection(); + } finally { + unawaited( + Future.delayed(Duration.zero, () { + _skipNextProjectionEmissionForForcedResync = false; + }), + ); + } + } +} + +bool _hasSameSignificantProjectionState( + WatchSessionProjection left, + WatchSessionProjection right, +) { + return left.schemaVersion == right.schemaVersion && + left.deviceSessionId == right.deviceSessionId && + left.revision == right.revision && + left.phase == right.phase && + left.phoneReachable == right.phoneReachable && + left.seriesIndex == right.seriesIndex && + left.seriesTotal == right.seriesTotal && + left.exerciseName == right.exerciseName && + left.programIndex == right.programIndex && + left.exerciseIndex == right.exerciseIndex && + left.setIndex == right.setIndex && + left.passageIndex == right.passageIndex && + left.passageTotal == right.passageTotal && + left.stepIndex == right.stepIndex && + left.stepTotal == right.stepTotal && + left.stepName == right.stepName && + left.stepType == right.stepType && + left.stepTargetValue == right.stepTargetValue && + _hasSameSignificantTimerState(left.dominantTimer, right.dominantTimer) && + _hasSameSignificantTimerListState( + left.secondaryTimers, + right.secondaryTimers, + ) && + left.primaryAction == right.primaryAction && + _listEquals(left.secondaryActions, right.secondaryActions) && + left.nextExerciseName == right.nextExerciseName && + left.statusLabel == right.statusLabel && + left.hasManualScore == right.hasManualScore && + left.currentManualScoreValue == right.currentManualScoreValue && + left.canDecrementScore == right.canDecrementScore && + left.manualScoreTargetValue == right.manualScoreTargetValue && + left.manualScoreTargetLabel == right.manualScoreTargetLabel && + left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue && + left.manualScoreScope == right.manualScoreScope; +} + +bool _hasSameSignificantTimerListState( + List left, + List right, +) { + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index += 1) { + if (!_hasSameSignificantTimerState(left[index], right[index])) { + return false; + } + } + return true; +} + +bool _hasSameSignificantTimerState( + WatchTimerProjection? left, + WatchTimerProjection? right, +) { + if (left == null || right == null) { + return left == right; + } + return left.kind == right.kind && + left.label == right.label && + left.displayMode == right.displayMode && + left.runState == right.runState && + left.targetMs == right.targetMs; +} + +bool _listEquals(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) { + return false; + } + } + return true; } final class _WatchAdapterCommandKey { diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart index 73c4afa..f905ee4 100644 --- a/test/application/watch_companion_command_handler_test.dart +++ b/test/application/watch_companion_command_handler_test.dart @@ -384,6 +384,8 @@ void main() { type: ExerciseStepType.reps, defaultTargetValue: 10, hasScore: true, + scoreLabel: 'Cible', + scoreUnit: 'pts', defaultTargetScore: 8, ), ], diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index bcbbdf4..954c88d 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -736,7 +736,10 @@ final class _FakeWatchProjectionPublisher implements WatchProjectionPublisher { final published = []; @override - Future publish(WatchSessionProjection projection) async { + Future publish( + WatchSessionProjection projection, { + bool urgent = true, + }) async { published.add(projection); } } diff --git a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart index 290de04..14d6f16 100644 --- a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart +++ b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart @@ -18,36 +18,89 @@ void main() { final source = _FakeProjectionSource(_projection(revision: 0)); final adapter = _adapter(native: native, source: source); await adapter.start(); + await Future.delayed(Duration.zero); native.published.clear(); - source.emit(_projection(revision: 1)); source.emit(_projection(revision: 2)); + source.emit(_projection(revision: 3)); await Future.delayed(Duration.zero); - expect(native.published.map((projection) => projection.revision), [1, 2]); + expect(native.published.map((projection) => projection.revision), [2, 3]); await adapter.stop(); }); - test('heartbeats while a timer is running', () async { + test('republishes volatile heartbeats as non urgent keepalives', () async { final native = _FakeWatchBridgeNativeChannel(); - final source = _FakeProjectionSource(_runningProjection(revision: 1)); + final source = _FakeProjectionSource( + _runningProjection(revision: 1), + incrementsRevisionOnEmit: false, + ); final adapter = _adapter( native: native, source: source, heartbeatInterval: const Duration(milliseconds: 10), ); await adapter.start(); + await Future.delayed(Duration.zero); native.published.clear(); + native.urgentFlags.clear(); source.emitCount = 0; - await adapter.publish(_runningProjection(revision: 1)); await Future.delayed(const Duration(milliseconds: 35)); expect(source.emitCount, greaterThanOrEqualTo(1)); - expect(native.published.length, greaterThanOrEqualTo(2)); + expect(native.published.length, greaterThanOrEqualTo(1)); + expect(native.urgentFlags, everyElement(false)); await adapter.stop(); }); + test('marks only projection revision changes as urgent', () async { + final native = _FakeWatchBridgeNativeChannel(); + final source = _FakeProjectionSource(_runningProjection(revision: 0)); + final adapter = _adapter(native: native, source: source); + await adapter.start(); + await Future.delayed(Duration.zero); + native.published.clear(); + native.urgentFlags.clear(); + + await adapter.publish(_runningProjection(revision: 2)); + await adapter.publish(_runningProjection(revision: 2)); + + expect(native.published.map((projection) => projection.revision), [2]); + expect(native.urgentFlags, [true]); + await adapter.stop(); + }); + + test( + 'forces an urgent projection resync even when state is unchanged', + () async { + final native = _FakeWatchBridgeNativeChannel(); + final source = _FakeProjectionSource( + _runningProjection(revision: 1), + incrementsRevisionOnEmit: false, + ); + final adapter = _adapter(native: native, source: source); + await adapter.start(); + await Future.delayed(Duration.zero); + native.published.clear(); + native.urgentFlags.clear(); + source.emitCount = 0; + + native.emitConnection( + const WatchBridgeConnectionEvent( + isReachable: true, + requestsResync: true, + ), + ); + await Future.delayed(Duration.zero); + + expect(source.emitCount, 1); + expect(native.published.map((projection) => projection.revision), [1]); + expect(native.urgentFlags, [true]); + await adapter.stop(); + }, + ); + test('dispatches watch command and sends ack back to native layer', () async { final native = _FakeWatchBridgeNativeChannel(); final ingress = _FakeCommandIngress(); @@ -89,6 +142,7 @@ void main() { final source = _FakeProjectionSource(_projection(revision: 3)); final adapter = _adapter(native: native, source: source); await adapter.start(); + await Future.delayed(Duration.zero); native.published.clear(); source.emitCount = 0; @@ -372,9 +426,10 @@ final class _FakeIds implements IdGenerator { } final class _FakeProjectionSource implements WatchProjectionSource { - _FakeProjectionSource(this.current); + _FakeProjectionSource(this.current, {this.incrementsRevisionOnEmit = true}); WatchSessionProjection current; + final bool incrementsRevisionOnEmit; var emitCount = 0; final _controller = StreamController.broadcast(); @@ -392,19 +447,44 @@ final class _FakeProjectionSource implements WatchProjectionSource { @override Future emitCurrentProjection() async { emitCount += 1; + final nextRevision = incrementsRevisionOnEmit + ? current.revision + 1 + : current.revision; current = WatchSessionProjection( deviceSessionId: current.deviceSessionId, - revision: current.revision + 1, - projectedAtEpochMs: current.projectedAtEpochMs, + revision: nextRevision, + projectedAtEpochMs: current.projectedAtEpochMs + 1000, + expiresAtEpochMs: current.expiresAtEpochMs == 0 + ? 0 + : current.expiresAtEpochMs + 1000, phase: current.phase, phoneReachable: current.phoneReachable, seriesIndex: current.seriesIndex, seriesTotal: current.seriesTotal, exerciseName: current.exerciseName, + programIndex: current.programIndex, + exerciseIndex: current.exerciseIndex, + setIndex: current.setIndex, + passageIndex: current.passageIndex, + passageTotal: current.passageTotal, + stepIndex: current.stepIndex, + stepTotal: current.stepTotal, + stepName: current.stepName, + stepType: current.stepType, + stepTargetValue: current.stepTargetValue, dominantTimer: current.dominantTimer, secondaryTimers: current.secondaryTimers, primaryAction: current.primaryAction, secondaryActions: current.secondaryActions, + nextExerciseName: current.nextExerciseName, + statusLabel: current.statusLabel, + hasManualScore: current.hasManualScore, + currentManualScoreValue: current.currentManualScoreValue, + canDecrementScore: current.canDecrementScore, + manualScoreTargetValue: current.manualScoreTargetValue, + manualScoreTargetLabel: current.manualScoreTargetLabel, + manualScoreRepsTargetValue: current.manualScoreRepsTargetValue, + manualScoreScope: current.manualScoreScope, ); _controller.add(current); return current; @@ -479,9 +559,15 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel { _connections.add(event); } + final urgentFlags = []; + @override - Future publishProjection(WatchSessionProjection projection) async { + Future publishProjection( + WatchSessionProjection projection, { + bool urgent = true, + }) async { published.add(projection); + urgentFlags.add(urgent); } @override diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt index 6956278..89bd94a 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt @@ -38,6 +38,7 @@ class WatchBridgeListenerService : WearableListenerService() { requestsResync = capabilityInfo.nodes.isNotEmpty(), ) if (capabilityInfo.nodes.isNotEmpty()) { + WatchBridgePlugin.resyncLiveStatsAfterReconnect(applicationContext) WatchBridgePlugin.requestLatestProjection(applicationContext) } } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index 4f7d57c..c321794 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -275,6 +275,7 @@ object WatchBridgePlugin { "requestResync" -> { requestLatestProjection(context) requestCapabilityRefresh(context) + resyncLiveStatsAfterReconnect(context) result.success(null) } "invalidateActiveProjection" -> { @@ -291,7 +292,7 @@ object WatchBridgePlugin { activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) } activeProjectionExpiryRunnable = null WatchOngoingActivityController.cancel(context) - WatchHeartRateForegroundService.stop(context) + WatchHeartRateForegroundService.stop(context, force = true) heartRateCollector.finishCurrentSession(context) } @@ -347,6 +348,9 @@ object WatchBridgePlugin { Wearable.getCapabilityClient(context) .getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE) .addOnSuccessListener { capability -> + if (capability.nodes.isNotEmpty()) { + resyncLiveStatsAfterReconnect(context) + } emitConnection( isReachable = capability.nodes.isNotEmpty(), requestsResync = capability.nodes.isNotEmpty(), @@ -357,6 +361,12 @@ object WatchBridgePlugin { } } + fun resyncLiveStatsAfterReconnect(context: Context) { + heartRateCollector.onPhoneReconnected(context) + val projection = lastSensorProjection ?: lastActiveProjection ?: return + updateHeartRateCollection(context, projection) + } + fun requestLatestProjection(context: Context) { val uri = Uri.Builder() .scheme("wear") @@ -468,13 +478,13 @@ object WatchBridgePlugin { val sessionId = projection["deviceSessionId"] as? String ?: "" if (phase == "noActiveSession" || sessionId.isBlank()) { lastSensorProjection = null - WatchHeartRateForegroundService.stop(context) + WatchHeartRateForegroundService.stop(context, force = phase == "noActiveSession") heartRateCollector.finishCurrentSession(context) return } lastSensorProjection = projection val shouldAggregate = phase == "running" - if (!hasRequiredSensorPermissions(context)) { + if (!hasRequiredRuntimePermissions(context)) { WatchHeartRateForegroundService.stop(context) heartRateCollector.noteActiveSession( sessionId, @@ -506,12 +516,18 @@ object WatchBridgePlugin { } } + private fun hasRequiredRuntimePermissions(context: Context): Boolean { + return requiredRuntimePermissions().all { permission -> + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + } + } + private fun requestSensorPermissionsOnce() { val activity = activity ?: run { Log.d(TAG, "sensor permission request pending: activity unavailable") return } - val permissions = requiredSensorPermissions() + val permissions = requiredRuntimePermissions() .filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } .toTypedArray() if (permissions.isEmpty()) { @@ -546,7 +562,7 @@ object WatchBridgePlugin { private fun requestPendingSensorPermissionIfPossible() { val context = appContext ?: return - if (!pendingSensorPermissionRequest || hasRequiredSensorPermissions(context)) { + if (!pendingSensorPermissionRequest || hasRequiredRuntimePermissions(context)) { return } requestSensorPermissionsOnce() @@ -565,6 +581,14 @@ object WatchBridgePlugin { ) } + private fun requiredRuntimePermissions(): List { + val permissions = requiredSensorPermissions().toMutableList() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(android.Manifest.permission.POST_NOTIFICATIONS) + } + return permissions + } + private fun telemetryContext(projection: Map): Map { return mapOf( "programIndex" to projection["programIndex"], diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index ccde604..9cae76a 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -1,6 +1,9 @@ package com.gametime.watch.bridge import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.health.services.client.ExerciseClient import androidx.health.services.client.ExerciseUpdateCallback @@ -16,6 +19,7 @@ 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.Node import com.google.android.gms.wearable.Wearable import org.json.JSONObject import java.nio.charset.StandardCharsets @@ -29,8 +33,16 @@ internal class WatchHeartRateCollector( ) { private companion object { const val TAG = "GTWatchHeartRate" + const val SAMPLE_FLUSH_INTERVAL_MS = 1500L + const val NODE_CACHE_TTL_MS = 10000L } + private val mainHandler = Handler(Looper.getMainLooper()) + private var pendingSample: Map? = null + private var sampleFlushRunnable: Runnable? = null + private var cachedReachableNodes: List = emptyList() + private var cachedReachableNodesAtEpochMs = 0L + private var nodeLookupInFlight = false private var sessionId: String? = null private var sampleCount = 0 private var sampleSum = 0.0 @@ -43,6 +55,8 @@ internal class WatchHeartRateCollector( private val registeredDataTypes = mutableSetOf>() private var exerciseMetricsStarted = false private var exerciseMetricsStartInFlight = false + private var exerciseHeartRateSupported = false + private var exerciseHeartRateObserved = false private var shouldAggregate = false private var appContext: Context? = null @@ -63,8 +77,7 @@ internal class WatchHeartRateCollector( latestHeartRateBpm = recordHeartRate(point.value) } if (latestHeartRateBpm != null) { - Log.d( - TAG, + logHotPath( "heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm", ) sendSample(latestHeartRateBpm) @@ -90,6 +103,14 @@ internal class WatchHeartRateCollector( return } var updated = false + var latestHeartRateBpm: Int? = null + for (point in update.latestMetrics.getData(DataType.HEART_RATE_BPM)) { + latestHeartRateBpm = recordHeartRate(point.value) + } + if (latestHeartRateBpm != null && !exerciseHeartRateObserved) { + exerciseHeartRateObserved = true + appContext?.let(::unregister) + } for (point in update.latestMetrics.getData(DataType.DISTANCE)) { val value = point.value if (value > 0) { @@ -104,12 +125,11 @@ internal class WatchHeartRateCollector( updated = true } } - if (updated) { - Log.d( - TAG, - "exercise metrics received sessionId=$sessionId distance=$distanceMeters calories=$caloriesKcal", + if (latestHeartRateBpm != null || updated) { + logHotPath( + "exercise metrics received sessionId=$sessionId bpm=$latestHeartRateBpm distance=$distanceMeters calories=$caloriesKcal", ) - sendSample(null) + sendSample(latestHeartRateBpm) } } @@ -145,23 +165,31 @@ internal class WatchHeartRateCollector( } } + fun onPhoneReconnected(context: Context) { + appContext = context.applicationContext + cachedReachableNodes = emptyList() + cachedReachableNodesAtEpochMs = 0L + flushPendingSample(context, forceNodeRefresh = true) + } + fun start(context: Context) { if (sessionId.isNullOrBlank()) { return } appContext = context.applicationContext - val measureClient = HealthServices.getClient(context).measureClient - registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) + startMeasureHeartRateFallback(context) startExerciseMetrics(context) } fun pause(context: Context) { shouldAggregate = false + flushPendingSample(context, forceNodeRefresh = false) unregister(context) stopExerciseMetrics(context) } fun finishCurrentSession(context: Context) { + flushPendingSample(context, forceNodeRefresh = false) unregister(context) stopExerciseMetrics(context) val completedSessionId = sessionId @@ -209,22 +237,8 @@ internal class WatchHeartRateCollector( "caloriesKcal" to caloriesKcal, ) onLocalSample(sample) - val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8) - Wearable.getCapabilityClient(context) - .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) - .addOnSuccessListener { capability -> - Log.d( - TAG, - "send sample sessionId=$activeSessionId bpm=$bpm distance=$distanceMeters calories=$caloriesKcal nodes=${capability.nodes.size}", - ) - for (node in capability.nodes) { - Wearable.getMessageClient(context) - .sendMessage(node.id, sensorSamplePath, payload) - } - } - .addOnFailureListener { error -> - Log.w(TAG, "sample capability lookup failed", error) - } + pendingSample = sample + scheduleSampleFlush(context) } private fun sendSummary(context: Context, completedSessionId: String) { @@ -245,6 +259,7 @@ internal class WatchHeartRateCollector( Wearable.getCapabilityClient(context) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .addOnSuccessListener { capability -> + cacheReachableNodes(capability.nodes.toList()) Log.d( TAG, "send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}", @@ -300,9 +315,17 @@ internal class WatchHeartRateCollector( val config = exerciseConfigFromCapabilities(capabilities) if (config == null) { exerciseMetricsStartInFlight = false - Log.w(TAG, "no exercise type supports distance metrics sessionId=$sessionId") + Log.w(TAG, "no exercise type supports heart rate or distance sessionId=$sessionId") + if (shouldAggregate) { + startMeasureHeartRateFallback(context) + } return@addListener } + if (!shouldAggregate) { + exerciseMetricsStartInFlight = false + return@addListener + } + exerciseHeartRateSupported = DataType.HEART_RATE_BPM in config.dataTypes exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback) val startFuture = exerciseClient.startExerciseAsync(config) startFuture.addListener( @@ -318,6 +341,10 @@ internal class WatchHeartRateCollector( } catch (error: Exception) { Log.w(TAG, "exercise metrics start failed", error) clearExerciseCallback(exerciseClient) + exerciseHeartRateSupported = false + if (shouldAggregate) { + startMeasureHeartRateFallback(context) + } } }, context.mainExecutor, @@ -340,6 +367,7 @@ internal class WatchHeartRateCollector( ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING, ExerciseType.WORKOUT, ) + var heartRateOnlyConfig: ExerciseConfig? = null for (exerciseType in requestedTypes) { if (exerciseType !in capabilities.supportedExerciseTypes) { continue @@ -348,23 +376,37 @@ internal class WatchHeartRateCollector( .supportedDataTypes val dataTypes = mutableSetOf>() if (DataType.DISTANCE !in supported) { - Log.w( - TAG, - "exercise type lacks distance type=$exerciseType supported=$supported", - ) + if (DataType.HEART_RATE_BPM !in supported) { + Log.w( + TAG, + "exercise type lacks distance and heart rate type=$exerciseType supported=$supported", + ) + continue + } + dataTypes.add(DataType.HEART_RATE_BPM) + if (heartRateOnlyConfig == null) { + heartRateOnlyConfig = ExerciseConfig.builder(exerciseType) + .setDataTypes(dataTypes) + .setIsAutoPauseAndResumeEnabled(false) + .setIsGpsEnabled(false) + .build() + } continue } dataTypes.add(DataType.DISTANCE) if (DataType.CALORIES in supported) { dataTypes.add(DataType.CALORIES) } + if (DataType.HEART_RATE_BPM in supported) { + dataTypes.add(DataType.HEART_RATE_BPM) + } return ExerciseConfig.builder(exerciseType) .setDataTypes(dataTypes) .setIsAutoPauseAndResumeEnabled(false) .setIsGpsEnabled(true) .build() } - return null + return heartRateOnlyConfig } private fun stopExerciseMetrics(context: Context) { @@ -378,6 +420,7 @@ internal class WatchHeartRateCollector( } exerciseMetricsStarted = false exerciseMetricsStartInFlight = false + exerciseHeartRateSupported = false } private fun clearExerciseCallback(exerciseClient: ExerciseClient) { @@ -389,6 +432,9 @@ internal class WatchHeartRateCollector( } private fun reset(nextSessionId: String?) { + sampleFlushRunnable?.let { mainHandler.removeCallbacks(it) } + sampleFlushRunnable = null + pendingSample = null sessionId = nextSessionId sampleCount = 0 sampleSum = 0.0 @@ -399,5 +445,102 @@ internal class WatchHeartRateCollector( sampleSequence = 0 executionContext = emptyMap() shouldAggregate = false + exerciseHeartRateSupported = false + exerciseHeartRateObserved = false + } + + private fun startMeasureHeartRateFallback(context: Context) { + if (exerciseHeartRateSupported) { + return + } + val measureClient = HealthServices.getClient(context).measureClient + registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) + Log.d(TAG, "heart rate fallback MeasureClient active sessionId=$sessionId") + } + + private fun logHotPath(message: String) { + val context = appContext ?: return + if ((context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) { + Log.d(TAG, message) + } + } + + private fun scheduleSampleFlush(context: Context) { + if (sampleFlushRunnable != null) { + return + } + val appContext = context.applicationContext + sampleFlushRunnable = Runnable { + sampleFlushRunnable = null + flushPendingSample(appContext, forceNodeRefresh = false) + }.also { runnable -> + mainHandler.postDelayed(runnable, SAMPLE_FLUSH_INTERVAL_MS) + } + } + + private fun flushPendingSample(context: Context, forceNodeRefresh: Boolean) { + val sample = pendingSample ?: return + pendingSample = null + sampleFlushRunnable?.let { mainHandler.removeCallbacks(it) } + sampleFlushRunnable = null + sendSampleToNodes(context, sample, forceNodeRefresh) + } + + private fun sendSampleToNodes( + context: Context, + sample: Map, + forceNodeRefresh: Boolean, + ) { + val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8) + val cachedNodes = cachedNodesIfFresh() + if (!forceNodeRefresh && cachedNodes.isNotEmpty()) { + sendSamplePayload(context, payload, cachedNodes, sample) + return + } + if (nodeLookupInFlight && cachedReachableNodes.isNotEmpty()) { + sendSamplePayload(context, payload, cachedReachableNodes, sample) + return + } + nodeLookupInFlight = true + Wearable.getCapabilityClient(context) + .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) + .addOnSuccessListener { capability -> + nodeLookupInFlight = false + val nodes = capability.nodes.toList() + cacheReachableNodes(nodes) + sendSamplePayload(context, payload, nodes, sample) + } + .addOnFailureListener { error -> + nodeLookupInFlight = false + Log.w(TAG, "sample capability lookup failed", error) + } + } + + private fun sendSamplePayload( + context: Context, + payload: ByteArray, + nodes: List, + sample: Map, + ) { + logHotPath( + "send sample sessionId=${sample["sessionId"]} bpm=${sample["heartRateBpm"]} distance=${sample["distanceMeters"]} calories=${sample["caloriesKcal"]} nodes=${nodes.size}", + ) + for (node in nodes) { + Wearable.getMessageClient(context) + .sendMessage(node.id, sensorSamplePath, payload) + } + } + + private fun cachedNodesIfFresh(): List { + val now = System.currentTimeMillis() + if (now - cachedReachableNodesAtEpochMs > NODE_CACHE_TTL_MS) { + return emptyList() + } + return cachedReachableNodes + } + + private fun cacheReachableNodes(nodes: List) { + cachedReachableNodes = nodes + cachedReachableNodesAtEpochMs = System.currentTimeMillis() } } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt index 6f425e2..c22b94a 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt @@ -21,17 +21,25 @@ internal class WatchHeartRateForegroundService : Service() { const val CHANNEL_NAME = "Collecte cardio GameTime" const val NOTIFICATION_ID = 9102 const val EXTRA_EXERCISE_NAME = "exerciseName" + private var lastCommandKey: String? = null + private var serviceRequested = false fun start(context: Context, projection: Map) { val sessionId = projection["deviceSessionId"] as? String ?: "" val phase = projection["phase"] as? String ?: "noActiveSession" if (sessionId.isBlank() || phase != "running") { - stop(context) + stop(context, force = phase == "noActiveSession") return } val exerciseName = (projection["exerciseName"] as? String) ?.takeIf { it.isNotBlank() } ?: "Séance en cours" + val key = "$sessionId|$phase|$exerciseName" + if (serviceRequested && key == lastCommandKey) { + return + } + serviceRequested = true + lastCommandKey = key ContextCompat.startForegroundService( context, Intent(context, WatchHeartRateForegroundService::class.java) @@ -39,7 +47,12 @@ internal class WatchHeartRateForegroundService : Service() { ) } - fun stop(context: Context) { + fun stop(context: Context, force: Boolean = false) { + if (!force && !serviceRequested) { + return + } + serviceRequested = false + lastCommandKey = null context.stopService(Intent(context, WatchHeartRateForegroundService::class.java)) } } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt index db50b3d..4774a89 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt @@ -21,25 +21,47 @@ object WatchOngoingActivityController { private const val CHANNEL_NAME = "Séance GameTime" private const val NOTIFICATION_ID = 9101 private const val ONGOING_ACTIVITY_ID = 9101 + private const val REPUBLISH_INTERVAL_MS = 60000L + private var lastAppliedKey: String? = null + private var lastAppliedAtEpochMs = 0L fun update(context: Context, projection: Map, activity: Activity?) { val phase = projection["phase"] as? String ?: "noActiveSession" val sessionId = projection["deviceSessionId"] as? String ?: "" if (phase == "noActiveSession" || sessionId.isBlank()) { - cancel(context) + cancel(context, force = phase == "noActiveSession") + return + } + val key = ongoingKey(projection) + val now = System.currentTimeMillis() + if (key == lastAppliedKey && now - lastAppliedAtEpochMs < REPUBLISH_INTERVAL_MS) { return } if (!hasPostNotificationsPermission(context)) { Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted") return } + lastAppliedKey = key + lastAppliedAtEpochMs = now post(context, projection) } - fun cancel(context: Context) { + fun cancel(context: Context, force: Boolean = true) { + if (!force && lastAppliedKey == null) { + return + } + lastAppliedKey = null + lastAppliedAtEpochMs = 0L NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) } + private fun ongoingKey(projection: Map): String { + val phase = projection["phase"] as? String ?: "noActiveSession" + val sessionId = projection["deviceSessionId"] as? String ?: "" + val exerciseName = projection["exerciseName"] as? String ?: "" + return "$sessionId|$phase|$exerciseName" + } + private fun post(context: Context, projection: Map) { ensureNotificationChannel(context) val touchIntent = PendingIntent.getActivity( diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index badcf68..649ae88 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -37,7 +37,8 @@ final class WatchSessionUiState { final WatchCommandAckEvent? lastAck; final WatchSensorSample? sensorSample; - bool get actionsEnabled => !commandPending && !connectionLost; + bool get actionsEnabled => + !commandPending && !connectionLost && !staleProjection; WatchSessionUiState copyWith({ WatchSessionProjection? projection, @@ -112,9 +113,6 @@ final class WatchSessionViewModel extends ValueNotifier { ); unawaited(_nativeClient.requestCapabilityRefresh()); unawaited(_nativeClient.requestResync()); - _freshnessTimer = Timer.periodic(const Duration(seconds: 1), (_) { - _syncFreshnessState(); - }); } final NativeWatchBridgeClient _nativeClient; @@ -136,6 +134,7 @@ final class WatchSessionViewModel extends ValueNotifier { final _pendingScoreCommandIds = {}; double? _optimisticManualScoreValue; DateTime? _lastProjectionReceivedAt; + bool _requiresAuthoritativeProjection = true; var _commandCounter = 0; var _commandFailureSerial = 0; @@ -211,6 +210,7 @@ final class WatchSessionViewModel extends ValueNotifier { Future _sendCommand(WatchCommandType type) async { if (!value.actionsEnabled || + _requiresAuthoritativeProjection || (_requiresActiveSession(type) && value.projection.deviceSessionId.isEmpty)) { return; @@ -263,6 +263,8 @@ final class WatchSessionViewModel extends ValueNotifier { Future _sendScoreCommand(WatchCommandType type, int delta) async { final projection = value.projection; if (value.connectionLost || + value.staleProjection || + _requiresAuthoritativeProjection || !projection.phoneReachable || !projection.hasManualScore || projection.deviceSessionId.isEmpty) { @@ -313,6 +315,7 @@ final class WatchSessionViewModel extends ValueNotifier { void _handleProjection(WatchSessionProjection projection) { final previousProjection = value.projection; _lastProjectionReceivedAt = DateTime.now(); + _requiresAuthoritativeProjection = false; _scheduleProjectionExpiry(projection); _pendingCommand = null; _clearCommandTimers(); @@ -334,6 +337,7 @@ final class WatchSessionViewModel extends ValueNotifier { : null, ); _triggerProjectionHaptic(previousProjection, projection); + _scheduleFreshnessCheck(); } void _handleSensorSample(WatchSensorSample sample) { @@ -403,34 +407,30 @@ final class WatchSessionViewModel extends ValueNotifier { void _handleConnectionEvent(WatchBridgeConnectionEvent event) { value = value.copyWith(connectionLost: !event.isReachable); + if (!event.isReachable) { + _requiresAuthoritativeProjection = true; + _clearScorePending(recalibrate: true); + return; + } if (event.isReachable || event.requestsResync) { + _requiresAuthoritativeProjection = true; unawaited(_nativeClient.requestResync()); } } void _syncFreshnessState() { - final receivedAt = _lastProjectionReceivedAt; - if (receivedAt == null) { + _freshnessTimer = null; + if (_lastProjectionReceivedAt == null) { return; } - final now = DateTime.now(); - final expiryAge = Duration( - milliseconds: _projectionTtlMs(value.projection), - ); - final expired = - value.projection.deviceSessionId.isNotEmpty && - now.difference(receivedAt) >= expiryAge && - _pendingCommand == null && - _pendingScoreCommandIds.isEmpty; - if (expired) { - _invalidateExpiredProjection(); + if (!value.staleProjection) { + _requiresAuthoritativeProjection = true; + value = value.copyWith(staleProjection: true); + _scheduleFreshnessCheck(); return; } - final age = now.difference(receivedAt); - final stale = age >= _staleProjectionThreshold; - final lost = age >= _connectionLostThreshold; - if (stale != value.staleProjection || lost != value.connectionLost) { - value = value.copyWith(staleProjection: stale, connectionLost: lost); + if (!value.connectionLost) { + value = value.copyWith(connectionLost: true); } } @@ -446,6 +446,8 @@ final class WatchSessionViewModel extends ValueNotifier { _scoreCommandTimeoutTimer?.cancel(); _scoreCommandTimeoutTimer = null; _lastProjectionReceivedAt = null; + _freshnessTimer?.cancel(); + _freshnessTimer = null; value = WatchSessionUiState( projection: _expiredProjection(), connectionLost: true, @@ -474,6 +476,30 @@ final class WatchSessionViewModel extends ValueNotifier { }); } + void _scheduleFreshnessCheck() { + _freshnessTimer?.cancel(); + _freshnessTimer = null; + final receivedAt = _lastProjectionReceivedAt; + if (receivedAt == null) { + return; + } + final Duration? nextThreshold; + if (!value.staleProjection) { + nextThreshold = _staleProjectionThreshold; + } else if (!value.connectionLost) { + nextThreshold = _connectionLostThreshold - _staleProjectionThreshold; + } else { + nextThreshold = null; + } + if (nextThreshold == null) { + return; + } + _freshnessTimer = Timer( + nextThreshold.isNegative ? Duration.zero : nextThreshold, + _syncFreshnessState, + ); + } + void _clearCommandTimers() { _waitingTimer?.cancel(); _waitingTimer = null; diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index 7a84008..d650505 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -6,10 +6,21 @@ import 'package:watch_bridge_contract/watch_bridge_contract.dart'; import '../application/watch_session_view_model.dart'; +typedef WatchNowEpochMs = int Function(); + +int _defaultNowEpochMs() { + return DateTime.now().toUtc().millisecondsSinceEpoch; +} + final class WatchSessionScreen extends StatefulWidget { - const WatchSessionScreen({required this.viewModel, super.key}); + const WatchSessionScreen({ + required this.viewModel, + this.nowEpochMs = _defaultNowEpochMs, + super.key, + }); final WatchSessionViewModel viewModel; + final WatchNowEpochMs nowEpochMs; @override State createState() => _WatchSessionScreenState(); @@ -30,11 +41,6 @@ final class _WatchSessionScreenState extends State { void initState() { super.initState(); _pageController = PageController(); - _ticker = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) { - setState(() {}); - } - }); } @override @@ -53,7 +59,9 @@ final class _WatchSessionScreenState extends State { _syncFailureNotice(state); _syncSecondaryNavigation(state); final projection = state.projection; - _syncTimerCompletionHaptic(projection); + final nowEpochMs = widget.nowEpochMs(); + _syncTimerCompletionHaptic(projection, nowEpochMs: nowEpochMs); + _syncUiTicker(projection); if (projection.phase == WatchSessionPhase.noActiveSession) { final phoneReachable = projection.phoneReachable && !state.connectionLost; @@ -78,6 +86,7 @@ final class _WatchSessionScreenState extends State { onIncrementScore: widget.viewModel.incrementScore, onDecrementScore: widget.viewModel.decrementScore, onCompleteStep: widget.viewModel.completeCurrentStep, + nowEpochMs: nowEpochMs, ), ), _RoundScaffold( @@ -207,7 +216,10 @@ final class _WatchSessionScreenState extends State { }); } - void _syncTimerCompletionHaptic(WatchSessionProjection projection) { + void _syncTimerCompletionHaptic( + WatchSessionProjection projection, { + required int nowEpochMs, + }) { final timer = _primaryDisplayTimer(projection); if (timer == null || timer.displayMode != WatchTimerDisplayMode.countdown || @@ -215,7 +227,10 @@ final class _WatchSessionScreenState extends State { return; } final key = _timerHapticKey(projection, timer); - final remainingMs = _displayDuration(timer).inMilliseconds; + final remainingMs = _displayDuration( + timer, + nowEpochMs: nowEpochMs, + ).inMilliseconds; final previousRemainingMs = _timerRemainingMsByKey[key]; _timerRemainingMsByKey[key] = remainingMs; if (remainingMs > 0 || @@ -227,6 +242,20 @@ final class _WatchSessionScreenState extends State { _triggerTimerCompletionHaptic(); } + void _syncUiTicker(WatchSessionProjection projection) { + final shouldTick = _hasRunningVisibleTimer(projection); + if (shouldTick) { + _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() {}); + } + }); + return; + } + _ticker?.cancel(); + _ticker = null; + } + void _triggerTimerCompletionHaptic() { unawaited(HapticFeedback.heavyImpact()); unawaited( @@ -460,6 +489,7 @@ final class _SessionMainView extends StatelessWidget { required this.onIncrementScore, required this.onDecrementScore, required this.onCompleteStep, + required this.nowEpochMs, }); final WatchSessionUiState state; @@ -469,6 +499,7 @@ final class _SessionMainView extends StatelessWidget { final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; final VoidCallback onCompleteStep; + final int nowEpochMs; @override Widget build(BuildContext context) { @@ -484,7 +515,7 @@ final class _SessionMainView extends StatelessWidget { final canToggleTimer = showsTimer && !connectionLost && - !state.commandPending && + state.actionsEnabled && _timerButtonCommandMatches( timer: timer, primaryAction: projection.primaryAction, @@ -512,6 +543,7 @@ final class _SessionMainView extends StatelessWidget { ? _RestContent( state: state, onTogglePause: canToggleTimer ? onTogglePause : null, + nowEpochMs: nowEpochMs, ) : _ActiveContent( state: state, @@ -519,6 +551,7 @@ final class _SessionMainView extends StatelessWidget { onIncrementScore: onIncrementScore, onDecrementScore: onDecrementScore, onCompleteStep: onCompleteStep, + nowEpochMs: nowEpochMs, ), ), ), @@ -767,6 +800,7 @@ final class _ActiveContent extends StatelessWidget { required this.onIncrementScore, required this.onDecrementScore, required this.onCompleteStep, + required this.nowEpochMs, }); final WatchSessionUiState state; @@ -774,6 +808,7 @@ final class _ActiveContent extends StatelessWidget { final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; final VoidCallback onCompleteStep; + final int nowEpochMs; @override Widget build(BuildContext context) { @@ -784,6 +819,7 @@ final class _ActiveContent extends StatelessWidget { onTogglePause: onTogglePause, onIncrement: onIncrementScore, onDecrement: onDecrementScore, + nowEpochMs: nowEpochMs, ); } final timer = _primaryDisplayTimer(projection); @@ -794,12 +830,12 @@ final class _ActiveContent extends StatelessWidget { final repsTarget = _repsStepTarget(projection); final dominantValue = timer == null ? _seriesValue(projection) - : _timerText(timer); + : _timerText(timer, nowEpochMs: nowEpochMs); final dominantLabel = timer == null ? 'SÉRIE' : timer.label; final controlsEnabled = !state.connectionLost && projection.phoneReachable && - !state.commandPending; + state.actionsEnabled; return _ScaledContent( child: Column( mainAxisSize: MainAxisSize.min, @@ -833,7 +869,12 @@ final class _ActiveContent extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 4), child: Text( - secondaryTimers.map(_compactTimerText).join(' · '), + secondaryTimers + .map( + (timer) => + _compactTimerText(timer, nowEpochMs: nowEpochMs), + ) + .join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -956,12 +997,14 @@ final class _ManualScoreContent extends StatelessWidget { required this.onTogglePause, required this.onIncrement, required this.onDecrement, + required this.nowEpochMs, }); final WatchSessionUiState state; final VoidCallback? onTogglePause; final VoidCallback onIncrement; final VoidCallback onDecrement; + final int nowEpochMs; @override Widget build(BuildContext context) { @@ -970,7 +1013,7 @@ final class _ManualScoreContent extends StatelessWidget { state.optimisticManualScoreValue ?? projection.currentManualScoreValue ?? 0; - final controlsEnabled = !state.connectionLost && projection.phoneReachable; + final controlsEnabled = state.actionsEnabled && projection.phoneReachable; final canDecrement = controlsEnabled && score > 0 && @@ -1047,6 +1090,7 @@ final class _ManualScoreContent extends StatelessWidget { timer: timer, pending: state.timerTogglePending, onTogglePause: canToggleTimer ? onTogglePause : null, + nowEpochMs: nowEpochMs, ) else _StatusLine(projection.statusLabel), @@ -1061,11 +1105,13 @@ final class _CompactTimerLine extends StatelessWidget { required this.timer, required this.pending, required this.onTogglePause, + required this.nowEpochMs, }); final WatchTimerProjection timer; final bool pending; final VoidCallback? onTogglePause; + final int nowEpochMs; @override Widget build(BuildContext context) { @@ -1081,7 +1127,7 @@ final class _CompactTimerLine extends StatelessWidget { children: [ Flexible( child: Text( - _timerText(timer), + _timerText(timer, nowEpochMs: nowEpochMs), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -1191,10 +1237,15 @@ final class _PendingDot extends StatelessWidget { } final class _RestContent extends StatelessWidget { - const _RestContent({required this.state, required this.onTogglePause}); + const _RestContent({ + required this.state, + required this.onTogglePause, + required this.nowEpochMs, + }); final WatchSessionUiState state; final VoidCallback? onTogglePause; + final int nowEpochMs; @override Widget build(BuildContext context) { @@ -1209,7 +1260,7 @@ final class _RestContent extends StatelessWidget { if (timer != null) ...[ const SizedBox(height: 2), _DominantTimerLine( - value: _timerText(timer), + value: _timerText(timer, nowEpochMs: nowEpochMs), timer: timer, pending: state.timerTogglePending, onTogglePause: onTogglePause, @@ -1675,16 +1726,30 @@ List _visibleSecondaryTimers( .toList(growable: false); } -String _timerText(WatchTimerProjection timer) { - final duration = _displayDuration(timer); +bool _hasRunningVisibleTimer(WatchSessionProjection projection) { + final primaryTimer = _primaryDisplayTimer(projection); + if (primaryTimer?.runState == WatchTimerRunState.running) { + return true; + } + return _visibleSecondaryTimers( + projection, + primaryTimer: primaryTimer, + ).any((timer) => timer.runState == WatchTimerRunState.running); +} + +String _timerText(WatchTimerProjection timer, {required int nowEpochMs}) { + final duration = _displayDuration(timer, nowEpochMs: nowEpochMs); final totalSeconds = duration.inSeconds; final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } -String _compactTimerText(WatchTimerProjection timer) { - return '${timer.label} ${_timerText(timer)}'; +String _compactTimerText( + WatchTimerProjection timer, { + required int nowEpochMs, +}) { + return '${timer.label} ${_timerText(timer, nowEpochMs: nowEpochMs)}'; } String _timerHapticKey( @@ -1731,8 +1796,11 @@ String? _caloriesLabel(WatchSensorSample? sample) { return '${calories.round()} kcal'; } -Duration _displayDuration(WatchTimerProjection timer) { - final elapsedMs = _interpolatedElapsedMs(timer); +Duration _displayDuration( + WatchTimerProjection timer, { + required int nowEpochMs, +}) { + final elapsedMs = _interpolatedElapsedMs(timer, nowEpochMs: nowEpochMs); final displayMs = switch (timer.displayMode) { WatchTimerDisplayMode.elapsed => elapsedMs, WatchTimerDisplayMode.countdown => (timer.targetMs ?? 0) - elapsedMs, @@ -1740,13 +1808,15 @@ Duration _displayDuration(WatchTimerProjection timer) { return Duration(milliseconds: displayMs < 0 ? 0 : displayMs); } -int _interpolatedElapsedMs(WatchTimerProjection timer) { +int _interpolatedElapsedMs( + WatchTimerProjection timer, { + required int nowEpochMs, +}) { if (timer.runState != WatchTimerRunState.running || timer.startedAtEpochMs == null) { return timer.accumulatedMs; } - final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; - final elapsedSinceReference = (nowMs - timer.referenceEpochMs).clamp( + final elapsedSinceReference = (nowEpochMs - timer.referenceEpochMs).clamp( 0, 1 << 31, ); diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 6cd21a3..95d12c9 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -713,6 +713,128 @@ void main() { }, ); + testWidgets('blocks score commands until reconnect projection resyncs', ( + tester, + ) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel(nativeClient: client); + + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); + + client.emitProjection(_manualScoreProjection()); + await tester.pump(); + + client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false)); + await tester.pump(); + client.emitConnection( + const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true), + ); + await tester.pump(); + + await viewModel.incrementScore(); + await tester.pump(); + + expect(client.sentCommands, isEmpty); + expect(client.resyncRequests, greaterThanOrEqualTo(2)); + + client.emitProjection(_manualScoreProjection()); + await tester.pump(); + + await viewModel.incrementScore(); + await tester.pump(); + + expect(client.sentCommands.single.type, WatchCommandType.incrementScore); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + + testWidgets('blocks session actions until reconnect projection resyncs', ( + tester, + ) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel(nativeClient: client); + + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); + + client.emitProjection(_runningProjection()); + await tester.pump(); + + client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false)); + await tester.pump(); + client.emitConnection( + const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true), + ); + await tester.pump(); + + await viewModel.sendPrimaryAction(); + await tester.pump(); + + expect(client.sentCommands, isEmpty); + expect(client.resyncRequests, greaterThanOrEqualTo(2)); + + client.emitProjection(_runningProjection()); + await tester.pump(); + + await viewModel.sendPrimaryAction(); + await tester.pump(); + + expect(client.sentCommands.single.type, WatchCommandType.pauseSession); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + + testWidgets('blocks score commands while projection is stale', ( + tester, + ) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel( + nativeClient: client, + staleProjectionThreshold: const Duration(milliseconds: 50), + connectionLostThreshold: const Duration(milliseconds: 150), + ); + + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); + + client.emitProjection( + _manualScoreProjection(expiresIn: const Duration(seconds: 3)), + ); + await tester.pump(const Duration(milliseconds: 60)); + + await viewModel.incrementScore(); + await tester.pump(); + + expect(viewModel.value.staleProjection, isTrue); + expect(client.sentCommands, isEmpty); + + client.emitProjection(_manualScoreProjection()); + await tester.pump(); + + await viewModel.incrementScore(); + await tester.pump(); + + expect(client.sentCommands.single.type, WatchCommandType.incrementScore); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + testWidgets( 'uses a strong pulse sequence when a countdown timer reaches zero', (tester) async { @@ -767,6 +889,103 @@ void main() { }, ); + testWidgets( + 'keeps ticking a visible running countdown until completion without a new projection', + (tester) async { + final hapticCalls = []; + 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); + var nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + + 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, + nowEpochMs: () => nowMs, + ), + ), + ); + + client.emitProjection( + _liveCountdownProjection(remainingMs: 1200, nowMs: nowMs), + ); + await tester.pump(); + expect(find.text('00:01'), findsOneWidget); + expect(hapticCalls, isEmpty); + + nowMs += 1300; + await tester.pump(const Duration(milliseconds: 1300)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('00:00'), findsOneWidget); + expect(hapticCalls, hasLength(3)); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }, + ); + + testWidgets('does not tick a visible paused countdown', (tester) async { + final hapticCalls = []; + 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(_pausedCountdownProjection(remainingMs: 1200)); + await tester.pump(); + expect(find.text('00:01'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 2300)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('00:01'), findsOneWidget); + expect(hapticCalls, isEmpty); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + testWidgets('expires an orphaned active projection after its TTL', ( tester, ) async { @@ -801,6 +1020,91 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }); + + testWidgets('marks projection freshness only when thresholds are reached', ( + tester, + ) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel( + nativeClient: client, + staleProjectionThreshold: const Duration(milliseconds: 100), + connectionLostThreshold: const Duration(milliseconds: 220), + ); + + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); + + client.emitProjection( + _expiringProjection(expiresIn: const Duration(seconds: 3)), + ); + await tester.pump(); + + expect(viewModel.value.staleProjection, isFalse); + expect(viewModel.value.connectionLost, isFalse); + + await tester.pump(const Duration(milliseconds: 90)); + expect(viewModel.value.staleProjection, isFalse); + expect(viewModel.value.connectionLost, isFalse); + + await tester.pump(const Duration(milliseconds: 20)); + expect(viewModel.value.staleProjection, isTrue); + expect(viewModel.value.connectionLost, isFalse); + expect(find.text('Dernier état reçu'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 130)); + expect(viewModel.value.staleProjection, isTrue); + expect(viewModel.value.connectionLost, isTrue); + expect(find.text('Connexion au téléphone perdue'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + + testWidgets('reconnects and refreshes freshness state immediately', ( + tester, + ) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel( + nativeClient: client, + staleProjectionThreshold: const Duration(milliseconds: 100), + connectionLostThreshold: const Duration(milliseconds: 220), + ); + + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); + + client.emitProjection( + _expiringProjection(expiresIn: const Duration(seconds: 3)), + ); + await tester.pump(const Duration(milliseconds: 240)); + expect(viewModel.value.connectionLost, isTrue); + + client.emitConnection(const WatchBridgeConnectionEvent(isReachable: true)); + await tester.pump(); + + expect(viewModel.value.connectionLost, isFalse); + expect(viewModel.value.staleProjection, isTrue); + expect(client.resyncRequests, greaterThanOrEqualTo(2)); + + client.emitProjection( + _expiringProjection(expiresIn: const Duration(seconds: 3)), + ); + await tester.pump(); + + expect(viewModel.value.staleProjection, isFalse); + expect(viewModel.value.connectionLost, isFalse); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); } final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { @@ -1037,11 +1341,15 @@ void _expectSessionPageVisible(WidgetTester tester) { ); } -WatchSessionProjection _manualScoreProjection() { +WatchSessionProjection _manualScoreProjection({ + Duration expiresIn = const Duration(seconds: 12), +}) { + final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch; return WatchSessionProjection( deviceSessionId: 'session-1', revision: 2, - projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + projectedAtEpochMs: projectedAt, + expiresAtEpochMs: projectedAt + expiresIn.inMilliseconds, phase: WatchSessionPhase.running, phoneReachable: true, seriesIndex: 1, @@ -1159,6 +1467,60 @@ WatchSessionProjection _countdownProjection({required int accumulatedMs}) { ); } +WatchSessionProjection _liveCountdownProjection({ + required int remainingMs, + required int nowMs, +}) { + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: remainingMs, + projectedAtEpochMs: nowMs, + phase: WatchSessionPhase.running, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 3, + exerciseName: 'Gainage', + statusLabel: 'Chrono étape', + primaryAction: WatchPrimaryAction.pauseSession, + dominantTimer: WatchTimerProjection( + kind: WatchTimerKind.step, + label: 'Chrono étape', + displayMode: WatchTimerDisplayMode.countdown, + runState: WatchTimerRunState.running, + referenceEpochMs: nowMs, + accumulatedMs: 30000 - remainingMs, + startedAtEpochMs: nowMs, + targetMs: 30000, + ), + ); +} + +WatchSessionProjection _pausedCountdownProjection({required int remainingMs}) { + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: remainingMs, + projectedAtEpochMs: nowMs, + phase: WatchSessionPhase.running, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 3, + exerciseName: 'Gainage', + statusLabel: 'Chrono étape', + primaryAction: WatchPrimaryAction.resumeSession, + dominantTimer: WatchTimerProjection( + kind: WatchTimerKind.step, + label: 'Chrono étape', + displayMode: WatchTimerDisplayMode.countdown, + runState: WatchTimerRunState.paused, + referenceEpochMs: nowMs, + accumulatedMs: 30000 - remainingMs, + startedAtEpochMs: null, + targetMs: 30000, + ), + ); +} + WatchTimerProjection _runningStepTimer() { return WatchTimerProjection( kind: WatchTimerKind.step,