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

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

View File

@ -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<void> _commandTail = Future<void>.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<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;
_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<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 {