fix(watch): stabilise stats live, foreground et resync apres perte de connexion (#179-#189)

Reduit le cout radio des samples live et la cadence des projections telephone -> montre (#179-#183).
Restaure les statistiques live FC/distance/calories et le maintien foreground/ongoing activity (#184-#185).
Fiabilise le demarrage de seance et l'orchestration des permissions montre (#187).
Renforce la resynchronisation des statistiques live et du score apres perte puis retour de connexion (#188-#189).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 08:08:45 +02:00
parent 639231e3fd
commit 7130635177
15 changed files with 1023 additions and 119 deletions

View File

@ -169,20 +169,27 @@ object WatchBridgePlugin {
result.error("invalid_projection", "Projection payload must be a map.", null) result.error("invalid_projection", "Projection payload must be a map.", null)
return return
} }
val projectionJson = JSONObject(map).toString() val projection = map.filterKeys { it != "urgent" }
val projectionJson = JSONObject(projection).toString()
val request = PutDataMapRequest.create(STATE_PATH).apply { val request = PutDataMapRequest.create(STATE_PATH).apply {
dataMap.putString("projectionJson", projectionJson) dataMap.putString("projectionJson", projectionJson)
dataMap.putInt("schemaVersion", (map["schemaVersion"] as? Number)?.toInt() ?: 1) dataMap.putInt(
dataMap.putInt("revision", (map["revision"] as? Number)?.toInt() ?: 0) "schemaVersion",
(projection["schemaVersion"] as? Number)?.toInt() ?: 1,
)
dataMap.putInt("revision", (projection["revision"] as? Number)?.toInt() ?: 0)
dataMap.putLong( dataMap.putLong(
"projectedAtEpochMs", "projectedAtEpochMs",
(map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L, (projection["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
) )
dataMap.putLong( dataMap.putLong(
"expiresAtEpochMs", "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) Wearable.getDataClient(context).putDataItem(request)
.addOnSuccessListener { result.success(null) } .addOnSuccessListener { result.success(null) }
.addOnFailureListener { error -> .addOnFailureListener { error ->

View File

@ -5,7 +5,7 @@ abstract interface class WatchCommandIngress {
} }
abstract interface class WatchProjectionPublisher { abstract interface class WatchProjectionPublisher {
Future<void> publish(WatchSessionProjection projection); Future<void> publish(WatchSessionProjection projection, {bool urgent = true});
} }
abstract interface class WatchAlertPublisher { abstract interface class WatchAlertPublisher {

View File

@ -22,7 +22,10 @@ abstract interface class WatchBridgeNativeChannel {
Stream<WatchBridgeConnectionEvent> get connectionEvents; Stream<WatchBridgeConnectionEvent> get connectionEvents;
Future<void> publishProjection(WatchSessionProjection projection); Future<void> publishProjection(
WatchSessionProjection projection, {
bool urgent = true,
});
Future<void> publishAlert(WatchAlertEnvelope alert); Future<void> publishAlert(WatchAlertEnvelope alert);
@ -124,11 +127,14 @@ final class MethodChannelWatchBridgeNativeChannel
} }
@override @override
Future<void> publishProjection(WatchSessionProjection projection) { Future<void> publishProjection(
return _invokeIgnoringMissingPlugin( WatchSessionProjection projection, {
'publishProjection', bool urgent = true,
projection.toJson(), }) {
); return _invokeIgnoringMissingPlugin('publishProjection', {
...projection.toJson(),
'urgent': urgent,
});
} }
@override @override

View File

@ -15,7 +15,7 @@ final class WatchWearDataLayerAdapter
WorkoutHistoryUseCases? workoutHistoryUseCases, WorkoutHistoryUseCases? workoutHistoryUseCases,
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases, ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
WorkoutTelemetryUseCases? workoutTelemetryUseCases, WorkoutTelemetryUseCases? workoutTelemetryUseCases,
Duration projectionRefreshInterval = const Duration(seconds: 2), Duration projectionRefreshInterval = const Duration(seconds: 5),
}) : _nativeChannel = nativeChannel, }) : _nativeChannel = nativeChannel,
_commandIngress = commandIngress, _commandIngress = commandIngress,
_projectionSource = projectionSource, _projectionSource = projectionSource,
@ -36,6 +36,9 @@ final class WatchWearDataLayerAdapter
Future<void> _commandTail = Future<void>.value(); Future<void> _commandTail = Future<void>.value();
Timer? _projectionRefreshTimer; Timer? _projectionRefreshTimer;
WatchSessionProjection? _latestProjection; WatchSessionProjection? _latestProjection;
WatchSessionProjection? _lastPublishedProjection;
bool _skipNextProjectionEmissionForForcedResync = false;
int? _lastPublishedProjectionRevision;
bool _started = false; bool _started = false;
bool _foregroundActive = false; bool _foregroundActive = false;
@ -47,6 +50,10 @@ final class WatchWearDataLayerAdapter
_ensureProjectionRefreshLoop(); _ensureProjectionRefreshLoop();
_subscriptions.add( _subscriptions.add(
_projectionSource.projections.listen((projection) { _projectionSource.projections.listen((projection) {
if (_skipNextProjectionEmissionForForcedResync) {
_skipNextProjectionEmissionForForcedResync = false;
return;
}
unawaited(publish(projection)); unawaited(publish(projection));
}), }),
); );
@ -77,7 +84,7 @@ final class WatchWearDataLayerAdapter
_subscriptions.add( _subscriptions.add(
_nativeChannel.connectionEvents.listen((event) { _nativeChannel.connectionEvents.listen((event) {
if (event.isReachable || event.requestsResync) { if (event.isReachable || event.requestsResync) {
unawaited(_projectionSource.emitCurrentProjection()); unawaited(_forceProjectionResync());
} }
}), }),
); );
@ -96,7 +103,18 @@ final class WatchWearDataLayerAdapter
} }
@override @override
Future<void> publish(WatchSessionProjection projection) async { Future<void> publish(
WatchSessionProjection projection, {
bool urgent = true,
}) async {
await _publishProjection(projection, urgent: urgent, force: false);
}
Future<void> _publishProjection(
WatchSessionProjection projection, {
required bool urgent,
required bool force,
}) async {
final previousProjection = _latestProjection; final previousProjection = _latestProjection;
_latestProjection = projection; _latestProjection = projection;
if (projection.phase == WatchSessionPhase.noActiveSession) { if (projection.phase == WatchSessionPhase.noActiveSession) {
@ -105,7 +123,23 @@ final class WatchWearDataLayerAdapter
_activeWorkoutSensorUseCases?.clear(previousSessionId); _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); await _syncForegroundService(projection);
} }
@ -180,9 +214,114 @@ final class WatchWearDataLayerAdapter
void _ensureProjectionRefreshLoop() { void _ensureProjectionRefreshLoop() {
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) { _projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
unawaited(_projectionSource.emitCurrentProjection()); unawaited(_publishHeartbeat());
}); });
} }
Future<void> _publishHeartbeat() async {
final projection = await _emitCurrentProjectionSkippingSourceEcho();
await _publishProjection(projection, urgent: false, force: true);
}
Future<void> _forceProjectionResync() async {
final projection = await _emitCurrentProjectionSkippingSourceEcho();
await _publishProjection(projection, urgent: true, force: true);
}
Future<WatchSessionProjection>
_emitCurrentProjectionSkippingSourceEcho() async {
_skipNextProjectionEmissionForForcedResync = true;
try {
return await _projectionSource.emitCurrentProjection();
} finally {
unawaited(
Future<void>.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<WatchTimerProjection> left,
List<WatchTimerProjection> 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<T>(List<T> left, List<T> 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 { final class _WatchAdapterCommandKey {

View File

@ -384,6 +384,8 @@ void main() {
type: ExerciseStepType.reps, type: ExerciseStepType.reps,
defaultTargetValue: 10, defaultTargetValue: 10,
hasScore: true, hasScore: true,
scoreLabel: 'Cible',
scoreUnit: 'pts',
defaultTargetScore: 8, defaultTargetScore: 8,
), ),
], ],

View File

@ -736,7 +736,10 @@ final class _FakeWatchProjectionPublisher implements WatchProjectionPublisher {
final published = <WatchSessionProjection>[]; final published = <WatchSessionProjection>[];
@override @override
Future<void> publish(WatchSessionProjection projection) async { Future<void> publish(
WatchSessionProjection projection, {
bool urgent = true,
}) async {
published.add(projection); published.add(projection);
} }
} }

View File

@ -18,36 +18,89 @@ void main() {
final source = _FakeProjectionSource(_projection(revision: 0)); final source = _FakeProjectionSource(_projection(revision: 0));
final adapter = _adapter(native: native, source: source); final adapter = _adapter(native: native, source: source);
await adapter.start(); await adapter.start();
await Future<void>.delayed(Duration.zero);
native.published.clear(); native.published.clear();
source.emit(_projection(revision: 1));
source.emit(_projection(revision: 2)); source.emit(_projection(revision: 2));
source.emit(_projection(revision: 3));
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect(native.published.map((projection) => projection.revision), [1, 2]); expect(native.published.map((projection) => projection.revision), [2, 3]);
await adapter.stop(); await adapter.stop();
}); });
test('heartbeats while a timer is running', () async { test('republishes volatile heartbeats as non urgent keepalives', () async {
final native = _FakeWatchBridgeNativeChannel(); final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_runningProjection(revision: 1)); final source = _FakeProjectionSource(
_runningProjection(revision: 1),
incrementsRevisionOnEmit: false,
);
final adapter = _adapter( final adapter = _adapter(
native: native, native: native,
source: source, source: source,
heartbeatInterval: const Duration(milliseconds: 10), heartbeatInterval: const Duration(milliseconds: 10),
); );
await adapter.start(); await adapter.start();
await Future<void>.delayed(Duration.zero);
native.published.clear(); native.published.clear();
native.urgentFlags.clear();
source.emitCount = 0; source.emitCount = 0;
await adapter.publish(_runningProjection(revision: 1));
await Future<void>.delayed(const Duration(milliseconds: 35)); await Future<void>.delayed(const Duration(milliseconds: 35));
expect(source.emitCount, greaterThanOrEqualTo(1)); 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(); 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<void>.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<void>.delayed(Duration.zero);
native.published.clear();
native.urgentFlags.clear();
source.emitCount = 0;
native.emitConnection(
const WatchBridgeConnectionEvent(
isReachable: true,
requestsResync: true,
),
);
await Future<void>.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 { test('dispatches watch command and sends ack back to native layer', () async {
final native = _FakeWatchBridgeNativeChannel(); final native = _FakeWatchBridgeNativeChannel();
final ingress = _FakeCommandIngress(); final ingress = _FakeCommandIngress();
@ -89,6 +142,7 @@ void main() {
final source = _FakeProjectionSource(_projection(revision: 3)); final source = _FakeProjectionSource(_projection(revision: 3));
final adapter = _adapter(native: native, source: source); final adapter = _adapter(native: native, source: source);
await adapter.start(); await adapter.start();
await Future<void>.delayed(Duration.zero);
native.published.clear(); native.published.clear();
source.emitCount = 0; source.emitCount = 0;
@ -372,9 +426,10 @@ final class _FakeIds implements IdGenerator {
} }
final class _FakeProjectionSource implements WatchProjectionSource { final class _FakeProjectionSource implements WatchProjectionSource {
_FakeProjectionSource(this.current); _FakeProjectionSource(this.current, {this.incrementsRevisionOnEmit = true});
WatchSessionProjection current; WatchSessionProjection current;
final bool incrementsRevisionOnEmit;
var emitCount = 0; var emitCount = 0;
final _controller = StreamController<WatchSessionProjection>.broadcast(); final _controller = StreamController<WatchSessionProjection>.broadcast();
@ -392,19 +447,44 @@ final class _FakeProjectionSource implements WatchProjectionSource {
@override @override
Future<WatchSessionProjection> emitCurrentProjection() async { Future<WatchSessionProjection> emitCurrentProjection() async {
emitCount += 1; emitCount += 1;
final nextRevision = incrementsRevisionOnEmit
? current.revision + 1
: current.revision;
current = WatchSessionProjection( current = WatchSessionProjection(
deviceSessionId: current.deviceSessionId, deviceSessionId: current.deviceSessionId,
revision: current.revision + 1, revision: nextRevision,
projectedAtEpochMs: current.projectedAtEpochMs, projectedAtEpochMs: current.projectedAtEpochMs + 1000,
expiresAtEpochMs: current.expiresAtEpochMs == 0
? 0
: current.expiresAtEpochMs + 1000,
phase: current.phase, phase: current.phase,
phoneReachable: current.phoneReachable, phoneReachable: current.phoneReachable,
seriesIndex: current.seriesIndex, seriesIndex: current.seriesIndex,
seriesTotal: current.seriesTotal, seriesTotal: current.seriesTotal,
exerciseName: current.exerciseName, 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, dominantTimer: current.dominantTimer,
secondaryTimers: current.secondaryTimers, secondaryTimers: current.secondaryTimers,
primaryAction: current.primaryAction, primaryAction: current.primaryAction,
secondaryActions: current.secondaryActions, 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); _controller.add(current);
return current; return current;
@ -479,9 +559,15 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
_connections.add(event); _connections.add(event);
} }
final urgentFlags = <bool>[];
@override @override
Future<void> publishProjection(WatchSessionProjection projection) async { Future<void> publishProjection(
WatchSessionProjection projection, {
bool urgent = true,
}) async {
published.add(projection); published.add(projection);
urgentFlags.add(urgent);
} }
@override @override

View File

@ -38,6 +38,7 @@ class WatchBridgeListenerService : WearableListenerService() {
requestsResync = capabilityInfo.nodes.isNotEmpty(), requestsResync = capabilityInfo.nodes.isNotEmpty(),
) )
if (capabilityInfo.nodes.isNotEmpty()) { if (capabilityInfo.nodes.isNotEmpty()) {
WatchBridgePlugin.resyncLiveStatsAfterReconnect(applicationContext)
WatchBridgePlugin.requestLatestProjection(applicationContext) WatchBridgePlugin.requestLatestProjection(applicationContext)
} }
} }

View File

@ -275,6 +275,7 @@ object WatchBridgePlugin {
"requestResync" -> { "requestResync" -> {
requestLatestProjection(context) requestLatestProjection(context)
requestCapabilityRefresh(context) requestCapabilityRefresh(context)
resyncLiveStatsAfterReconnect(context)
result.success(null) result.success(null)
} }
"invalidateActiveProjection" -> { "invalidateActiveProjection" -> {
@ -291,7 +292,7 @@ object WatchBridgePlugin {
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) } activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
activeProjectionExpiryRunnable = null activeProjectionExpiryRunnable = null
WatchOngoingActivityController.cancel(context) WatchOngoingActivityController.cancel(context)
WatchHeartRateForegroundService.stop(context) WatchHeartRateForegroundService.stop(context, force = true)
heartRateCollector.finishCurrentSession(context) heartRateCollector.finishCurrentSession(context)
} }
@ -347,6 +348,9 @@ object WatchBridgePlugin {
Wearable.getCapabilityClient(context) Wearable.getCapabilityClient(context)
.getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE) .getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability -> .addOnSuccessListener { capability ->
if (capability.nodes.isNotEmpty()) {
resyncLiveStatsAfterReconnect(context)
}
emitConnection( emitConnection(
isReachable = capability.nodes.isNotEmpty(), isReachable = capability.nodes.isNotEmpty(),
requestsResync = 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) { fun requestLatestProjection(context: Context) {
val uri = Uri.Builder() val uri = Uri.Builder()
.scheme("wear") .scheme("wear")
@ -468,13 +478,13 @@ object WatchBridgePlugin {
val sessionId = projection["deviceSessionId"] as? String ?: "" val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) { if (phase == "noActiveSession" || sessionId.isBlank()) {
lastSensorProjection = null lastSensorProjection = null
WatchHeartRateForegroundService.stop(context) WatchHeartRateForegroundService.stop(context, force = phase == "noActiveSession")
heartRateCollector.finishCurrentSession(context) heartRateCollector.finishCurrentSession(context)
return return
} }
lastSensorProjection = projection lastSensorProjection = projection
val shouldAggregate = phase == "running" val shouldAggregate = phase == "running"
if (!hasRequiredSensorPermissions(context)) { if (!hasRequiredRuntimePermissions(context)) {
WatchHeartRateForegroundService.stop(context) WatchHeartRateForegroundService.stop(context)
heartRateCollector.noteActiveSession( heartRateCollector.noteActiveSession(
sessionId, 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() { private fun requestSensorPermissionsOnce() {
val activity = activity ?: run { val activity = activity ?: run {
Log.d(TAG, "sensor permission request pending: activity unavailable") Log.d(TAG, "sensor permission request pending: activity unavailable")
return return
} }
val permissions = requiredSensorPermissions() val permissions = requiredRuntimePermissions()
.filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } .filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
.toTypedArray() .toTypedArray()
if (permissions.isEmpty()) { if (permissions.isEmpty()) {
@ -546,7 +562,7 @@ object WatchBridgePlugin {
private fun requestPendingSensorPermissionIfPossible() { private fun requestPendingSensorPermissionIfPossible() {
val context = appContext ?: return val context = appContext ?: return
if (!pendingSensorPermissionRequest || hasRequiredSensorPermissions(context)) { if (!pendingSensorPermissionRequest || hasRequiredRuntimePermissions(context)) {
return return
} }
requestSensorPermissionsOnce() requestSensorPermissionsOnce()
@ -565,6 +581,14 @@ object WatchBridgePlugin {
) )
} }
private fun requiredRuntimePermissions(): List<String> {
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<String, Any?>): Map<String, Any?> { private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
return mapOf( return mapOf(
"programIndex" to projection["programIndex"], "programIndex" to projection["programIndex"],

View File

@ -1,6 +1,9 @@
package com.gametime.watch.bridge package com.gametime.watch.bridge
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.health.services.client.ExerciseClient import androidx.health.services.client.ExerciseClient
import androidx.health.services.client.ExerciseUpdateCallback 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.ExerciseLapSummary
import androidx.health.services.client.data.ExerciseType import androidx.health.services.client.data.ExerciseType
import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Node
import com.google.android.gms.wearable.Wearable import com.google.android.gms.wearable.Wearable
import org.json.JSONObject import org.json.JSONObject
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@ -29,8 +33,16 @@ internal class WatchHeartRateCollector(
) { ) {
private companion object { private companion object {
const val TAG = "GTWatchHeartRate" 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<String, Any?>? = null
private var sampleFlushRunnable: Runnable? = null
private var cachedReachableNodes: List<Node> = emptyList()
private var cachedReachableNodesAtEpochMs = 0L
private var nodeLookupInFlight = false
private var sessionId: String? = null private var sessionId: String? = null
private var sampleCount = 0 private var sampleCount = 0
private var sampleSum = 0.0 private var sampleSum = 0.0
@ -43,6 +55,8 @@ internal class WatchHeartRateCollector(
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>() private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var exerciseMetricsStarted = false private var exerciseMetricsStarted = false
private var exerciseMetricsStartInFlight = false private var exerciseMetricsStartInFlight = false
private var exerciseHeartRateSupported = false
private var exerciseHeartRateObserved = false
private var shouldAggregate = false private var shouldAggregate = false
private var appContext: Context? = null private var appContext: Context? = null
@ -63,8 +77,7 @@ internal class WatchHeartRateCollector(
latestHeartRateBpm = recordHeartRate(point.value) latestHeartRateBpm = recordHeartRate(point.value)
} }
if (latestHeartRateBpm != null) { if (latestHeartRateBpm != null) {
Log.d( logHotPath(
TAG,
"heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm", "heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
) )
sendSample(latestHeartRateBpm) sendSample(latestHeartRateBpm)
@ -90,6 +103,14 @@ internal class WatchHeartRateCollector(
return return
} }
var updated = false 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)) { for (point in update.latestMetrics.getData(DataType.DISTANCE)) {
val value = point.value val value = point.value
if (value > 0) { if (value > 0) {
@ -104,12 +125,11 @@ internal class WatchHeartRateCollector(
updated = true updated = true
} }
} }
if (updated) { if (latestHeartRateBpm != null || updated) {
Log.d( logHotPath(
TAG, "exercise metrics received sessionId=$sessionId bpm=$latestHeartRateBpm distance=$distanceMeters calories=$caloriesKcal",
"exercise metrics received sessionId=$sessionId 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) { fun start(context: Context) {
if (sessionId.isNullOrBlank()) { if (sessionId.isNullOrBlank()) {
return return
} }
appContext = context.applicationContext appContext = context.applicationContext
val measureClient = HealthServices.getClient(context).measureClient startMeasureHeartRateFallback(context)
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
startExerciseMetrics(context) startExerciseMetrics(context)
} }
fun pause(context: Context) { fun pause(context: Context) {
shouldAggregate = false shouldAggregate = false
flushPendingSample(context, forceNodeRefresh = false)
unregister(context) unregister(context)
stopExerciseMetrics(context) stopExerciseMetrics(context)
} }
fun finishCurrentSession(context: Context) { fun finishCurrentSession(context: Context) {
flushPendingSample(context, forceNodeRefresh = false)
unregister(context) unregister(context)
stopExerciseMetrics(context) stopExerciseMetrics(context)
val completedSessionId = sessionId val completedSessionId = sessionId
@ -209,22 +237,8 @@ internal class WatchHeartRateCollector(
"caloriesKcal" to caloriesKcal, "caloriesKcal" to caloriesKcal,
) )
onLocalSample(sample) onLocalSample(sample)
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8) pendingSample = sample
Wearable.getCapabilityClient(context) scheduleSampleFlush(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)
}
} }
private fun sendSummary(context: Context, completedSessionId: String) { private fun sendSummary(context: Context, completedSessionId: String) {
@ -245,6 +259,7 @@ internal class WatchHeartRateCollector(
Wearable.getCapabilityClient(context) Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability -> .addOnSuccessListener { capability ->
cacheReachableNodes(capability.nodes.toList())
Log.d( Log.d(
TAG, TAG,
"send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}", "send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}",
@ -300,9 +315,17 @@ internal class WatchHeartRateCollector(
val config = exerciseConfigFromCapabilities(capabilities) val config = exerciseConfigFromCapabilities(capabilities)
if (config == null) { if (config == null) {
exerciseMetricsStartInFlight = false 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 return@addListener
} }
if (!shouldAggregate) {
exerciseMetricsStartInFlight = false
return@addListener
}
exerciseHeartRateSupported = DataType.HEART_RATE_BPM in config.dataTypes
exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback) exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback)
val startFuture = exerciseClient.startExerciseAsync(config) val startFuture = exerciseClient.startExerciseAsync(config)
startFuture.addListener( startFuture.addListener(
@ -318,6 +341,10 @@ internal class WatchHeartRateCollector(
} catch (error: Exception) { } catch (error: Exception) {
Log.w(TAG, "exercise metrics start failed", error) Log.w(TAG, "exercise metrics start failed", error)
clearExerciseCallback(exerciseClient) clearExerciseCallback(exerciseClient)
exerciseHeartRateSupported = false
if (shouldAggregate) {
startMeasureHeartRateFallback(context)
}
} }
}, },
context.mainExecutor, context.mainExecutor,
@ -340,6 +367,7 @@ internal class WatchHeartRateCollector(
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING, ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT, ExerciseType.WORKOUT,
) )
var heartRateOnlyConfig: ExerciseConfig? = null
for (exerciseType in requestedTypes) { for (exerciseType in requestedTypes) {
if (exerciseType !in capabilities.supportedExerciseTypes) { if (exerciseType !in capabilities.supportedExerciseTypes) {
continue continue
@ -348,23 +376,37 @@ internal class WatchHeartRateCollector(
.supportedDataTypes .supportedDataTypes
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>() val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
if (DataType.DISTANCE !in supported) { if (DataType.DISTANCE !in supported) {
Log.w( if (DataType.HEART_RATE_BPM !in supported) {
TAG, Log.w(
"exercise type lacks distance type=$exerciseType supported=$supported", 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 continue
} }
dataTypes.add(DataType.DISTANCE) dataTypes.add(DataType.DISTANCE)
if (DataType.CALORIES in supported) { if (DataType.CALORIES in supported) {
dataTypes.add(DataType.CALORIES) dataTypes.add(DataType.CALORIES)
} }
if (DataType.HEART_RATE_BPM in supported) {
dataTypes.add(DataType.HEART_RATE_BPM)
}
return ExerciseConfig.builder(exerciseType) return ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes) .setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false) .setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(true) .setIsGpsEnabled(true)
.build() .build()
} }
return null return heartRateOnlyConfig
} }
private fun stopExerciseMetrics(context: Context) { private fun stopExerciseMetrics(context: Context) {
@ -378,6 +420,7 @@ internal class WatchHeartRateCollector(
} }
exerciseMetricsStarted = false exerciseMetricsStarted = false
exerciseMetricsStartInFlight = false exerciseMetricsStartInFlight = false
exerciseHeartRateSupported = false
} }
private fun clearExerciseCallback(exerciseClient: ExerciseClient) { private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
@ -389,6 +432,9 @@ internal class WatchHeartRateCollector(
} }
private fun reset(nextSessionId: String?) { private fun reset(nextSessionId: String?) {
sampleFlushRunnable?.let { mainHandler.removeCallbacks(it) }
sampleFlushRunnable = null
pendingSample = null
sessionId = nextSessionId sessionId = nextSessionId
sampleCount = 0 sampleCount = 0
sampleSum = 0.0 sampleSum = 0.0
@ -399,5 +445,102 @@ internal class WatchHeartRateCollector(
sampleSequence = 0 sampleSequence = 0
executionContext = emptyMap() executionContext = emptyMap()
shouldAggregate = false 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<String, Any?>,
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<Node>,
sample: Map<String, Any?>,
) {
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<Node> {
val now = System.currentTimeMillis()
if (now - cachedReachableNodesAtEpochMs > NODE_CACHE_TTL_MS) {
return emptyList()
}
return cachedReachableNodes
}
private fun cacheReachableNodes(nodes: List<Node>) {
cachedReachableNodes = nodes
cachedReachableNodesAtEpochMs = System.currentTimeMillis()
} }
} }

View File

@ -21,17 +21,25 @@ internal class WatchHeartRateForegroundService : Service() {
const val CHANNEL_NAME = "Collecte cardio GameTime" const val CHANNEL_NAME = "Collecte cardio GameTime"
const val NOTIFICATION_ID = 9102 const val NOTIFICATION_ID = 9102
const val EXTRA_EXERCISE_NAME = "exerciseName" const val EXTRA_EXERCISE_NAME = "exerciseName"
private var lastCommandKey: String? = null
private var serviceRequested = false
fun start(context: Context, projection: Map<String, Any?>) { fun start(context: Context, projection: Map<String, Any?>) {
val sessionId = projection["deviceSessionId"] as? String ?: "" val sessionId = projection["deviceSessionId"] as? String ?: ""
val phase = projection["phase"] as? String ?: "noActiveSession" val phase = projection["phase"] as? String ?: "noActiveSession"
if (sessionId.isBlank() || phase != "running") { if (sessionId.isBlank() || phase != "running") {
stop(context) stop(context, force = phase == "noActiveSession")
return return
} }
val exerciseName = (projection["exerciseName"] as? String) val exerciseName = (projection["exerciseName"] as? String)
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: "Séance en cours" ?: "Séance en cours"
val key = "$sessionId|$phase|$exerciseName"
if (serviceRequested && key == lastCommandKey) {
return
}
serviceRequested = true
lastCommandKey = key
ContextCompat.startForegroundService( ContextCompat.startForegroundService(
context, context,
Intent(context, WatchHeartRateForegroundService::class.java) 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)) context.stopService(Intent(context, WatchHeartRateForegroundService::class.java))
} }
} }

View File

@ -21,25 +21,47 @@ object WatchOngoingActivityController {
private const val CHANNEL_NAME = "Séance GameTime" private const val CHANNEL_NAME = "Séance GameTime"
private const val NOTIFICATION_ID = 9101 private const val NOTIFICATION_ID = 9101
private const val ONGOING_ACTIVITY_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<String, Any?>, activity: Activity?) { fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
val phase = projection["phase"] as? String ?: "noActiveSession" val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: "" val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) { 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 return
} }
if (!hasPostNotificationsPermission(context)) { if (!hasPostNotificationsPermission(context)) {
Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted") Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted")
return return
} }
lastAppliedKey = key
lastAppliedAtEpochMs = now
post(context, projection) 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) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
} }
private fun ongoingKey(projection: Map<String, Any?>): 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<String, Any?>) { private fun post(context: Context, projection: Map<String, Any?>) {
ensureNotificationChannel(context) ensureNotificationChannel(context)
val touchIntent = PendingIntent.getActivity( val touchIntent = PendingIntent.getActivity(

View File

@ -37,7 +37,8 @@ final class WatchSessionUiState {
final WatchCommandAckEvent? lastAck; final WatchCommandAckEvent? lastAck;
final WatchSensorSample? sensorSample; final WatchSensorSample? sensorSample;
bool get actionsEnabled => !commandPending && !connectionLost; bool get actionsEnabled =>
!commandPending && !connectionLost && !staleProjection;
WatchSessionUiState copyWith({ WatchSessionUiState copyWith({
WatchSessionProjection? projection, WatchSessionProjection? projection,
@ -112,9 +113,6 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
); );
unawaited(_nativeClient.requestCapabilityRefresh()); unawaited(_nativeClient.requestCapabilityRefresh());
unawaited(_nativeClient.requestResync()); unawaited(_nativeClient.requestResync());
_freshnessTimer = Timer.periodic(const Duration(seconds: 1), (_) {
_syncFreshnessState();
});
} }
final NativeWatchBridgeClient _nativeClient; final NativeWatchBridgeClient _nativeClient;
@ -136,6 +134,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
final _pendingScoreCommandIds = <String>{}; final _pendingScoreCommandIds = <String>{};
double? _optimisticManualScoreValue; double? _optimisticManualScoreValue;
DateTime? _lastProjectionReceivedAt; DateTime? _lastProjectionReceivedAt;
bool _requiresAuthoritativeProjection = true;
var _commandCounter = 0; var _commandCounter = 0;
var _commandFailureSerial = 0; var _commandFailureSerial = 0;
@ -211,6 +210,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
Future<void> _sendCommand(WatchCommandType type) async { Future<void> _sendCommand(WatchCommandType type) async {
if (!value.actionsEnabled || if (!value.actionsEnabled ||
_requiresAuthoritativeProjection ||
(_requiresActiveSession(type) && (_requiresActiveSession(type) &&
value.projection.deviceSessionId.isEmpty)) { value.projection.deviceSessionId.isEmpty)) {
return; return;
@ -263,6 +263,8 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
Future<void> _sendScoreCommand(WatchCommandType type, int delta) async { Future<void> _sendScoreCommand(WatchCommandType type, int delta) async {
final projection = value.projection; final projection = value.projection;
if (value.connectionLost || if (value.connectionLost ||
value.staleProjection ||
_requiresAuthoritativeProjection ||
!projection.phoneReachable || !projection.phoneReachable ||
!projection.hasManualScore || !projection.hasManualScore ||
projection.deviceSessionId.isEmpty) { projection.deviceSessionId.isEmpty) {
@ -313,6 +315,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
void _handleProjection(WatchSessionProjection projection) { void _handleProjection(WatchSessionProjection projection) {
final previousProjection = value.projection; final previousProjection = value.projection;
_lastProjectionReceivedAt = DateTime.now(); _lastProjectionReceivedAt = DateTime.now();
_requiresAuthoritativeProjection = false;
_scheduleProjectionExpiry(projection); _scheduleProjectionExpiry(projection);
_pendingCommand = null; _pendingCommand = null;
_clearCommandTimers(); _clearCommandTimers();
@ -334,6 +337,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
: null, : null,
); );
_triggerProjectionHaptic(previousProjection, projection); _triggerProjectionHaptic(previousProjection, projection);
_scheduleFreshnessCheck();
} }
void _handleSensorSample(WatchSensorSample sample) { void _handleSensorSample(WatchSensorSample sample) {
@ -403,34 +407,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
void _handleConnectionEvent(WatchBridgeConnectionEvent event) { void _handleConnectionEvent(WatchBridgeConnectionEvent event) {
value = value.copyWith(connectionLost: !event.isReachable); value = value.copyWith(connectionLost: !event.isReachable);
if (!event.isReachable) {
_requiresAuthoritativeProjection = true;
_clearScorePending(recalibrate: true);
return;
}
if (event.isReachable || event.requestsResync) { if (event.isReachable || event.requestsResync) {
_requiresAuthoritativeProjection = true;
unawaited(_nativeClient.requestResync()); unawaited(_nativeClient.requestResync());
} }
} }
void _syncFreshnessState() { void _syncFreshnessState() {
final receivedAt = _lastProjectionReceivedAt; _freshnessTimer = null;
if (receivedAt == null) { if (_lastProjectionReceivedAt == null) {
return; return;
} }
final now = DateTime.now(); if (!value.staleProjection) {
final expiryAge = Duration( _requiresAuthoritativeProjection = true;
milliseconds: _projectionTtlMs(value.projection), value = value.copyWith(staleProjection: true);
); _scheduleFreshnessCheck();
final expired =
value.projection.deviceSessionId.isNotEmpty &&
now.difference(receivedAt) >= expiryAge &&
_pendingCommand == null &&
_pendingScoreCommandIds.isEmpty;
if (expired) {
_invalidateExpiredProjection();
return; return;
} }
final age = now.difference(receivedAt); if (!value.connectionLost) {
final stale = age >= _staleProjectionThreshold; value = value.copyWith(connectionLost: true);
final lost = age >= _connectionLostThreshold;
if (stale != value.staleProjection || lost != value.connectionLost) {
value = value.copyWith(staleProjection: stale, connectionLost: lost);
} }
} }
@ -446,6 +446,8 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
_scoreCommandTimeoutTimer?.cancel(); _scoreCommandTimeoutTimer?.cancel();
_scoreCommandTimeoutTimer = null; _scoreCommandTimeoutTimer = null;
_lastProjectionReceivedAt = null; _lastProjectionReceivedAt = null;
_freshnessTimer?.cancel();
_freshnessTimer = null;
value = WatchSessionUiState( value = WatchSessionUiState(
projection: _expiredProjection(), projection: _expiredProjection(),
connectionLost: true, connectionLost: true,
@ -474,6 +476,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
}); });
} }
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() { void _clearCommandTimers() {
_waitingTimer?.cancel(); _waitingTimer?.cancel();
_waitingTimer = null; _waitingTimer = null;

View File

@ -6,10 +6,21 @@ import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../application/watch_session_view_model.dart'; import '../application/watch_session_view_model.dart';
typedef WatchNowEpochMs = int Function();
int _defaultNowEpochMs() {
return DateTime.now().toUtc().millisecondsSinceEpoch;
}
final class WatchSessionScreen extends StatefulWidget { 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 WatchSessionViewModel viewModel;
final WatchNowEpochMs nowEpochMs;
@override @override
State<WatchSessionScreen> createState() => _WatchSessionScreenState(); State<WatchSessionScreen> createState() => _WatchSessionScreenState();
@ -30,11 +41,6 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_pageController = PageController(); _pageController = PageController();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) {
setState(() {});
}
});
} }
@override @override
@ -53,7 +59,9 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
_syncFailureNotice(state); _syncFailureNotice(state);
_syncSecondaryNavigation(state); _syncSecondaryNavigation(state);
final projection = state.projection; final projection = state.projection;
_syncTimerCompletionHaptic(projection); final nowEpochMs = widget.nowEpochMs();
_syncTimerCompletionHaptic(projection, nowEpochMs: nowEpochMs);
_syncUiTicker(projection);
if (projection.phase == WatchSessionPhase.noActiveSession) { if (projection.phase == WatchSessionPhase.noActiveSession) {
final phoneReachable = final phoneReachable =
projection.phoneReachable && !state.connectionLost; projection.phoneReachable && !state.connectionLost;
@ -78,6 +86,7 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
onIncrementScore: widget.viewModel.incrementScore, onIncrementScore: widget.viewModel.incrementScore,
onDecrementScore: widget.viewModel.decrementScore, onDecrementScore: widget.viewModel.decrementScore,
onCompleteStep: widget.viewModel.completeCurrentStep, onCompleteStep: widget.viewModel.completeCurrentStep,
nowEpochMs: nowEpochMs,
), ),
), ),
_RoundScaffold( _RoundScaffold(
@ -207,7 +216,10 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
}); });
} }
void _syncTimerCompletionHaptic(WatchSessionProjection projection) { void _syncTimerCompletionHaptic(
WatchSessionProjection projection, {
required int nowEpochMs,
}) {
final timer = _primaryDisplayTimer(projection); final timer = _primaryDisplayTimer(projection);
if (timer == null || if (timer == null ||
timer.displayMode != WatchTimerDisplayMode.countdown || timer.displayMode != WatchTimerDisplayMode.countdown ||
@ -215,7 +227,10 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
return; return;
} }
final key = _timerHapticKey(projection, timer); final key = _timerHapticKey(projection, timer);
final remainingMs = _displayDuration(timer).inMilliseconds; final remainingMs = _displayDuration(
timer,
nowEpochMs: nowEpochMs,
).inMilliseconds;
final previousRemainingMs = _timerRemainingMsByKey[key]; final previousRemainingMs = _timerRemainingMsByKey[key];
_timerRemainingMsByKey[key] = remainingMs; _timerRemainingMsByKey[key] = remainingMs;
if (remainingMs > 0 || if (remainingMs > 0 ||
@ -227,6 +242,20 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
_triggerTimerCompletionHaptic(); _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() { void _triggerTimerCompletionHaptic() {
unawaited(HapticFeedback.heavyImpact()); unawaited(HapticFeedback.heavyImpact());
unawaited( unawaited(
@ -460,6 +489,7 @@ final class _SessionMainView extends StatelessWidget {
required this.onIncrementScore, required this.onIncrementScore,
required this.onDecrementScore, required this.onDecrementScore,
required this.onCompleteStep, required this.onCompleteStep,
required this.nowEpochMs,
}); });
final WatchSessionUiState state; final WatchSessionUiState state;
@ -469,6 +499,7 @@ final class _SessionMainView extends StatelessWidget {
final VoidCallback onIncrementScore; final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore; final VoidCallback onDecrementScore;
final VoidCallback onCompleteStep; final VoidCallback onCompleteStep;
final int nowEpochMs;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -484,7 +515,7 @@ final class _SessionMainView extends StatelessWidget {
final canToggleTimer = final canToggleTimer =
showsTimer && showsTimer &&
!connectionLost && !connectionLost &&
!state.commandPending && state.actionsEnabled &&
_timerButtonCommandMatches( _timerButtonCommandMatches(
timer: timer, timer: timer,
primaryAction: projection.primaryAction, primaryAction: projection.primaryAction,
@ -512,6 +543,7 @@ final class _SessionMainView extends StatelessWidget {
? _RestContent( ? _RestContent(
state: state, state: state,
onTogglePause: canToggleTimer ? onTogglePause : null, onTogglePause: canToggleTimer ? onTogglePause : null,
nowEpochMs: nowEpochMs,
) )
: _ActiveContent( : _ActiveContent(
state: state, state: state,
@ -519,6 +551,7 @@ final class _SessionMainView extends StatelessWidget {
onIncrementScore: onIncrementScore, onIncrementScore: onIncrementScore,
onDecrementScore: onDecrementScore, onDecrementScore: onDecrementScore,
onCompleteStep: onCompleteStep, onCompleteStep: onCompleteStep,
nowEpochMs: nowEpochMs,
), ),
), ),
), ),
@ -767,6 +800,7 @@ final class _ActiveContent extends StatelessWidget {
required this.onIncrementScore, required this.onIncrementScore,
required this.onDecrementScore, required this.onDecrementScore,
required this.onCompleteStep, required this.onCompleteStep,
required this.nowEpochMs,
}); });
final WatchSessionUiState state; final WatchSessionUiState state;
@ -774,6 +808,7 @@ final class _ActiveContent extends StatelessWidget {
final VoidCallback onIncrementScore; final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore; final VoidCallback onDecrementScore;
final VoidCallback onCompleteStep; final VoidCallback onCompleteStep;
final int nowEpochMs;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -784,6 +819,7 @@ final class _ActiveContent extends StatelessWidget {
onTogglePause: onTogglePause, onTogglePause: onTogglePause,
onIncrement: onIncrementScore, onIncrement: onIncrementScore,
onDecrement: onDecrementScore, onDecrement: onDecrementScore,
nowEpochMs: nowEpochMs,
); );
} }
final timer = _primaryDisplayTimer(projection); final timer = _primaryDisplayTimer(projection);
@ -794,12 +830,12 @@ final class _ActiveContent extends StatelessWidget {
final repsTarget = _repsStepTarget(projection); final repsTarget = _repsStepTarget(projection);
final dominantValue = timer == null final dominantValue = timer == null
? _seriesValue(projection) ? _seriesValue(projection)
: _timerText(timer); : _timerText(timer, nowEpochMs: nowEpochMs);
final dominantLabel = timer == null ? 'SÉRIE' : timer.label; final dominantLabel = timer == null ? 'SÉRIE' : timer.label;
final controlsEnabled = final controlsEnabled =
!state.connectionLost && !state.connectionLost &&
projection.phoneReachable && projection.phoneReachable &&
!state.commandPending; state.actionsEnabled;
return _ScaledContent( return _ScaledContent(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -833,7 +869,12 @@ final class _ActiveContent extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
secondaryTimers.map(_compactTimerText).join(' · '), secondaryTimers
.map(
(timer) =>
_compactTimerText(timer, nowEpochMs: nowEpochMs),
)
.join(' · '),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -956,12 +997,14 @@ final class _ManualScoreContent extends StatelessWidget {
required this.onTogglePause, required this.onTogglePause,
required this.onIncrement, required this.onIncrement,
required this.onDecrement, required this.onDecrement,
required this.nowEpochMs,
}); });
final WatchSessionUiState state; final WatchSessionUiState state;
final VoidCallback? onTogglePause; final VoidCallback? onTogglePause;
final VoidCallback onIncrement; final VoidCallback onIncrement;
final VoidCallback onDecrement; final VoidCallback onDecrement;
final int nowEpochMs;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -970,7 +1013,7 @@ final class _ManualScoreContent extends StatelessWidget {
state.optimisticManualScoreValue ?? state.optimisticManualScoreValue ??
projection.currentManualScoreValue ?? projection.currentManualScoreValue ??
0; 0;
final controlsEnabled = !state.connectionLost && projection.phoneReachable; final controlsEnabled = state.actionsEnabled && projection.phoneReachable;
final canDecrement = final canDecrement =
controlsEnabled && controlsEnabled &&
score > 0 && score > 0 &&
@ -1047,6 +1090,7 @@ final class _ManualScoreContent extends StatelessWidget {
timer: timer, timer: timer,
pending: state.timerTogglePending, pending: state.timerTogglePending,
onTogglePause: canToggleTimer ? onTogglePause : null, onTogglePause: canToggleTimer ? onTogglePause : null,
nowEpochMs: nowEpochMs,
) )
else else
_StatusLine(projection.statusLabel), _StatusLine(projection.statusLabel),
@ -1061,11 +1105,13 @@ final class _CompactTimerLine extends StatelessWidget {
required this.timer, required this.timer,
required this.pending, required this.pending,
required this.onTogglePause, required this.onTogglePause,
required this.nowEpochMs,
}); });
final WatchTimerProjection timer; final WatchTimerProjection timer;
final bool pending; final bool pending;
final VoidCallback? onTogglePause; final VoidCallback? onTogglePause;
final int nowEpochMs;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1081,7 +1127,7 @@ final class _CompactTimerLine extends StatelessWidget {
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
_timerText(timer), _timerText(timer, nowEpochMs: nowEpochMs),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -1191,10 +1237,15 @@ final class _PendingDot extends StatelessWidget {
} }
final class _RestContent 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 WatchSessionUiState state;
final VoidCallback? onTogglePause; final VoidCallback? onTogglePause;
final int nowEpochMs;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1209,7 +1260,7 @@ final class _RestContent extends StatelessWidget {
if (timer != null) ...[ if (timer != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
_DominantTimerLine( _DominantTimerLine(
value: _timerText(timer), value: _timerText(timer, nowEpochMs: nowEpochMs),
timer: timer, timer: timer,
pending: state.timerTogglePending, pending: state.timerTogglePending,
onTogglePause: onTogglePause, onTogglePause: onTogglePause,
@ -1675,16 +1726,30 @@ List<WatchTimerProjection> _visibleSecondaryTimers(
.toList(growable: false); .toList(growable: false);
} }
String _timerText(WatchTimerProjection timer) { bool _hasRunningVisibleTimer(WatchSessionProjection projection) {
final duration = _displayDuration(timer); 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 totalSeconds = duration.inSeconds;
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds'; return '$minutes:$seconds';
} }
String _compactTimerText(WatchTimerProjection timer) { String _compactTimerText(
return '${timer.label} ${_timerText(timer)}'; WatchTimerProjection timer, {
required int nowEpochMs,
}) {
return '${timer.label} ${_timerText(timer, nowEpochMs: nowEpochMs)}';
} }
String _timerHapticKey( String _timerHapticKey(
@ -1731,8 +1796,11 @@ String? _caloriesLabel(WatchSensorSample? sample) {
return '${calories.round()} kcal'; return '${calories.round()} kcal';
} }
Duration _displayDuration(WatchTimerProjection timer) { Duration _displayDuration(
final elapsedMs = _interpolatedElapsedMs(timer); WatchTimerProjection timer, {
required int nowEpochMs,
}) {
final elapsedMs = _interpolatedElapsedMs(timer, nowEpochMs: nowEpochMs);
final displayMs = switch (timer.displayMode) { final displayMs = switch (timer.displayMode) {
WatchTimerDisplayMode.elapsed => elapsedMs, WatchTimerDisplayMode.elapsed => elapsedMs,
WatchTimerDisplayMode.countdown => (timer.targetMs ?? 0) - elapsedMs, WatchTimerDisplayMode.countdown => (timer.targetMs ?? 0) - elapsedMs,
@ -1740,13 +1808,15 @@ Duration _displayDuration(WatchTimerProjection timer) {
return Duration(milliseconds: displayMs < 0 ? 0 : displayMs); return Duration(milliseconds: displayMs < 0 ? 0 : displayMs);
} }
int _interpolatedElapsedMs(WatchTimerProjection timer) { int _interpolatedElapsedMs(
WatchTimerProjection timer, {
required int nowEpochMs,
}) {
if (timer.runState != WatchTimerRunState.running || if (timer.runState != WatchTimerRunState.running ||
timer.startedAtEpochMs == null) { timer.startedAtEpochMs == null) {
return timer.accumulatedMs; return timer.accumulatedMs;
} }
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; final elapsedSinceReference = (nowEpochMs - timer.referenceEpochMs).clamp(
final elapsedSinceReference = (nowMs - timer.referenceEpochMs).clamp(
0, 0,
1 << 31, 1 << 31,
); );

View File

@ -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( testWidgets(
'uses a strong pulse sequence when a countdown timer reaches zero', 'uses a strong pulse sequence when a countdown timer reaches zero',
(tester) async { (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 = <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);
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 = <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(_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', ( testWidgets('expires an orphaned active projection after its TTL', (
tester, tester,
) async { ) async {
@ -801,6 +1020,91 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose(); 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 { 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( return WatchSessionProjection(
deviceSessionId: 'session-1', deviceSessionId: 'session-1',
revision: 2, revision: 2,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, projectedAtEpochMs: projectedAt,
expiresAtEpochMs: projectedAt + expiresIn.inMilliseconds,
phase: WatchSessionPhase.running, phase: WatchSessionPhase.running,
phoneReachable: true, phoneReachable: true,
seriesIndex: 1, 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() { WatchTimerProjection _runningStepTimer() {
return WatchTimerProjection( return WatchTimerProjection(
kind: WatchTimerKind.step, kind: WatchTimerKind.step,