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

@ -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<WatchSessionUiState> {
);
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<WatchSessionUiState> {
final _pendingScoreCommandIds = <String>{};
double? _optimisticManualScoreValue;
DateTime? _lastProjectionReceivedAt;
bool _requiresAuthoritativeProjection = true;
var _commandCounter = 0;
var _commandFailureSerial = 0;
@ -211,6 +210,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
Future<void> _sendCommand(WatchCommandType type) async {
if (!value.actionsEnabled ||
_requiresAuthoritativeProjection ||
(_requiresActiveSession(type) &&
value.projection.deviceSessionId.isEmpty)) {
return;
@ -263,6 +263,8 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
Future<void> _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<WatchSessionUiState> {
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<WatchSessionUiState> {
: null,
);
_triggerProjectionHaptic(previousProjection, projection);
_scheduleFreshnessCheck();
}
void _handleSensorSample(WatchSensorSample sample) {
@ -403,34 +407,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
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<WatchSessionUiState> {
_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<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() {
_waitingTimer?.cancel();
_waitingTimer = null;

View File

@ -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<WatchSessionScreen> createState() => _WatchSessionScreenState();
@ -30,11 +41,6 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
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<WatchSessionScreen> {
_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<WatchSessionScreen> {
onIncrementScore: widget.viewModel.incrementScore,
onDecrementScore: widget.viewModel.decrementScore,
onCompleteStep: widget.viewModel.completeCurrentStep,
nowEpochMs: nowEpochMs,
),
),
_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);
if (timer == null ||
timer.displayMode != WatchTimerDisplayMode.countdown ||
@ -215,7 +227,10 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
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<WatchSessionScreen> {
_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<WatchTimerProjection> _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,
);