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

@ -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,
);