fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)

This commit is contained in:
2026-07-29 11:22:17 +02:00
parent 6f913e4e8d
commit 30c6259748
28 changed files with 1658 additions and 248 deletions

View File

@ -128,6 +128,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
Timer? _scoreWaitingTimer;
Timer? _scoreCommandTimeoutTimer;
Timer? _freshnessTimer;
Timer? _projectionExpiryTimer;
Timer? _commandFailureClearTimer;
WatchCommandEnvelope? _pendingCommand;
final _pendingScoreCommandIds = <String>{};
@ -198,6 +199,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
_scoreWaitingTimer?.cancel();
_scoreCommandTimeoutTimer?.cancel();
_freshnessTimer?.cancel();
_projectionExpiryTimer?.cancel();
_commandFailureClearTimer?.cancel();
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
@ -309,6 +311,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
void _handleProjection(WatchSessionProjection projection) {
final previousProjection = value.projection;
_lastProjectionReceivedAt = DateTime.now();
_scheduleProjectionExpiry(projection);
_pendingCommand = null;
_clearCommandTimers();
_syncScorePendingFromProjection(projection);
@ -388,7 +391,23 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
if (receivedAt == null) {
return;
}
final age = DateTime.now().difference(receivedAt);
final now = DateTime.now();
final expiresAtEpochMs = value.projection.expiresAtEpochMs;
final fallbackExpired =
expiresAtEpochMs <= 0 &&
now.difference(receivedAt) >= const Duration(seconds: 12);
final expired =
value.projection.deviceSessionId.isNotEmpty &&
(fallbackExpired ||
(expiresAtEpochMs > 0 &&
now.toUtc().millisecondsSinceEpoch >= expiresAtEpochMs)) &&
_pendingCommand == null &&
_pendingScoreCommandIds.isEmpty;
if (expired) {
_invalidateExpiredProjection();
return;
}
final age = now.difference(receivedAt);
final stale = age >= _staleProjectionThreshold;
final lost = age >= _connectionLostThreshold;
if (stale != value.staleProjection || lost != value.connectionLost) {
@ -396,6 +415,53 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
}
}
void _invalidateExpiredProjection() {
_projectionExpiryTimer?.cancel();
_projectionExpiryTimer = null;
_pendingCommand = null;
_pendingScoreCommandIds.clear();
_optimisticManualScoreValue = null;
_clearCommandTimers();
_scoreWaitingTimer?.cancel();
_scoreWaitingTimer = null;
_scoreCommandTimeoutTimer?.cancel();
_scoreCommandTimeoutTimer = null;
_lastProjectionReceivedAt = null;
value = WatchSessionUiState(
projection: _expiredProjection(),
connectionLost: true,
staleProjection: true,
commandFailureMessage: value.commandFailureMessage,
commandFailureSerial: value.commandFailureSerial,
lastAck: value.lastAck,
);
unawaited(_nativeClient.invalidateActiveProjection());
}
void _scheduleProjectionExpiry(WatchSessionProjection projection) {
_projectionExpiryTimer?.cancel();
_projectionExpiryTimer = null;
if (projection.deviceSessionId.isEmpty) {
return;
}
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
final expiresAtEpochMs = projection.expiresAtEpochMs > 0
? projection.expiresAtEpochMs
: nowMs + const Duration(seconds: 12).inMilliseconds;
final delayMs = expiresAtEpochMs - nowMs;
_projectionExpiryTimer = Timer(
Duration(milliseconds: delayMs <= 0 ? 0 : delayMs),
() {
if (value.projection.deviceSessionId.isEmpty ||
_pendingCommand != null ||
_pendingScoreCommandIds.isNotEmpty) {
return;
}
_invalidateExpiredProjection();
},
);
}
void _clearCommandTimers() {
_waitingTimer?.cancel();
_waitingTimer = null;
@ -504,10 +570,29 @@ bool _requiresActiveSession(WatchCommandType type) {
}
WatchSessionProjection _initialProjection() {
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: '',
revision: 0,
projectedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
projectedAtEpochMs: nowMs,
expiresAtEpochMs: nowMs,
phase: WatchSessionPhase.noActiveSession,
phoneReachable: false,
seriesIndex: 0,
seriesTotal: 0,
exerciseName: '',
primaryAction: WatchPrimaryAction.none,
statusLabel: 'Téléphone indisponible',
);
}
WatchSessionProjection _expiredProjection() {
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: '',
revision: 0,
projectedAtEpochMs: nowMs,
expiresAtEpochMs: nowMs,
phase: WatchSessionPhase.noActiveSession,
phoneReachable: false,
seriesIndex: 0,