fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user