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:
@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user