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,

View File

@ -41,6 +41,8 @@ abstract interface class NativeWatchBridgeClient {
Future<void> requestResync();
Future<void> requestCapabilityRefresh();
Future<void> invalidateActiveProjection();
}
final class MethodChannelNativeWatchBridgeClient
@ -141,6 +143,11 @@ final class MethodChannelNativeWatchBridgeClient
Future<void> requestResync() {
return _methodChannel.invokeMethod<void>('requestResync');
}
@override
Future<void> invalidateActiveProjection() {
return _methodChannel.invokeMethod<void>('invalidateActiveProjection');
}
}
Map<String, Object?> _stringObjectMap(Object? value) {

View File

@ -99,7 +99,11 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
),
);
}
return PageView(controller: _pageController, children: pages);
return PageView(
controller: _pageController,
physics: const _WatchPageScrollPhysics(),
children: pages,
);
},
);
}
@ -220,7 +224,21 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
!_completionHapticTimerKeys.add(key)) {
return;
}
_triggerTimerCompletionHaptic();
}
void _triggerTimerCompletionHaptic() {
unawaited(HapticFeedback.heavyImpact());
unawaited(
Future<void>.delayed(const Duration(milliseconds: 140), () {
return HapticFeedback.heavyImpact();
}),
);
unawaited(
Future<void>.delayed(const Duration(milliseconds: 320), () {
return HapticFeedback.heavyImpact();
}),
);
}
Future<bool> _confirm({
@ -242,6 +260,47 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
}
}
final class _WatchPageScrollPhysics extends PageScrollPhysics {
const _WatchPageScrollPhysics({super.parent});
@override
_WatchPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _WatchPageScrollPhysics(parent: buildParent(ancestor));
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
(velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
return super.createBallisticSimulation(position, velocity);
}
final viewport = position is PageMetrics
? position.viewportDimension * position.viewportFraction
: position.viewportDimension;
if (viewport <= 0) {
return null;
}
final target = (position.pixels / viewport).roundToDouble() * viewport;
final clampedTarget = target.clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if (clampedTarget == position.pixels) {
return null;
}
return ScrollSpringSimulation(
spring,
position.pixels,
clampedTarget,
velocity,
tolerance: toleranceFor(position),
);
}
}
final class _RoundScaffold extends StatelessWidget {
const _RoundScaffold({required this.child, this.notice, super.key});
@ -927,17 +986,21 @@ final class _ManualScoreContent extends StatelessWidget {
);
final target = projection.manualScoreTargetValue;
final targetLabel = projection.manualScoreTargetLabel;
final captionSegments = [
if (projection.manualScoreRepsTargetValue != null)
'Répétitions : ${projection.manualScoreRepsTargetValue}',
if (target != null && targetLabel != null && targetLabel.isNotEmpty)
'$targetLabel : ${_scoreText(target)}',
];
return _ScaledContent(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ExerciseName(projection.exerciseName),
_StepNameBand(projection.stepName),
if (target != null &&
targetLabel != null &&
targetLabel.isNotEmpty) ...[
if (captionSegments.isNotEmpty) ...[
Text(
'$targetLabel : ${_scoreText(target)}',
captionSegments.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,