import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; import '../infrastructure/watch_bridge/native_watch_bridge_client.dart'; final class WatchSessionUiState { const WatchSessionUiState({ required this.projection, this.commandPending = false, this.waitingForPhone = false, this.timerTogglePending = false, this.connectionLost = false, this.staleProjection = false, this.scoreCommandPending = false, this.scoreWaitingForPhone = false, this.optimisticManualScoreValue, this.commandFailureMessage, this.commandFailureSerial = 0, this.lastAck, this.sensorSample, }); final WatchSessionProjection projection; final bool commandPending; final bool waitingForPhone; final bool timerTogglePending; final bool connectionLost; final bool staleProjection; final bool scoreCommandPending; final bool scoreWaitingForPhone; final double? optimisticManualScoreValue; final String? commandFailureMessage; final int commandFailureSerial; final WatchCommandAckEvent? lastAck; final WatchSensorSample? sensorSample; bool get actionsEnabled => !commandPending && !connectionLost; WatchSessionUiState copyWith({ WatchSessionProjection? projection, bool? commandPending, bool? waitingForPhone, bool? timerTogglePending, bool? connectionLost, bool? staleProjection, bool? scoreCommandPending, bool? scoreWaitingForPhone, double? optimisticManualScoreValue, bool clearOptimisticManualScoreValue = false, String? commandFailureMessage, bool clearCommandFailureMessage = false, int? commandFailureSerial, WatchCommandAckEvent? lastAck, WatchSensorSample? sensorSample, bool clearSensorSample = false, }) { return WatchSessionUiState( projection: projection ?? this.projection, commandPending: commandPending ?? this.commandPending, waitingForPhone: waitingForPhone ?? this.waitingForPhone, timerTogglePending: timerTogglePending ?? this.timerTogglePending, connectionLost: connectionLost ?? this.connectionLost, staleProjection: staleProjection ?? this.staleProjection, scoreCommandPending: scoreCommandPending ?? this.scoreCommandPending, scoreWaitingForPhone: scoreWaitingForPhone ?? this.scoreWaitingForPhone, optimisticManualScoreValue: clearOptimisticManualScoreValue ? null : optimisticManualScoreValue ?? this.optimisticManualScoreValue, commandFailureMessage: clearCommandFailureMessage ? null : commandFailureMessage ?? this.commandFailureMessage, commandFailureSerial: commandFailureSerial ?? this.commandFailureSerial, lastAck: lastAck ?? this.lastAck, sensorSample: clearSensorSample ? null : sensorSample ?? this.sensorSample, ); } bool get hasLiveSensors { final sample = sensorSample; return sample != null && (sample.heartRateBpm != null || sample.distanceMeters != null || sample.caloriesKcal != null); } } final class WatchSessionViewModel extends ValueNotifier { WatchSessionViewModel({ NativeWatchBridgeClient nativeClient = const MethodChannelNativeWatchBridgeClient(), Duration waitingThreshold = const Duration(milliseconds: 500), Duration commandTimeout = const Duration(seconds: 2), Duration staleProjectionThreshold = const Duration(seconds: 6), Duration connectionLostThreshold = const Duration(seconds: 10), }) : _nativeClient = nativeClient, _waitingThreshold = waitingThreshold, _commandTimeout = commandTimeout, _staleProjectionThreshold = staleProjectionThreshold, _connectionLostThreshold = connectionLostThreshold, super(WatchSessionUiState(projection: _initialProjection())) { _subscriptions.add(_nativeClient.projections.listen(_handleProjection)); _subscriptions.add(_nativeClient.sensorSamples.listen(_handleSensorSample)); _subscriptions.add(_nativeClient.acks.listen(_handleAck)); _subscriptions.add(_nativeClient.alerts.listen(_handleAlert)); _subscriptions.add( _nativeClient.connectionEvents.listen(_handleConnectionEvent), ); unawaited(_nativeClient.requestCapabilityRefresh()); unawaited(_nativeClient.requestResync()); _freshnessTimer = Timer.periodic(const Duration(seconds: 1), (_) { _syncFreshnessState(); }); } final NativeWatchBridgeClient _nativeClient; final Duration _waitingThreshold; final Duration _commandTimeout; final Duration _staleProjectionThreshold; final Duration _connectionLostThreshold; final _subscriptions = >[]; final _handledAlertIds = {}; Timer? _waitingTimer; Timer? _commandTimeoutTimer; Timer? _scoreWaitingTimer; Timer? _scoreCommandTimeoutTimer; Timer? _freshnessTimer; Timer? _projectionExpiryTimer; Timer? _commandFailureClearTimer; WatchCommandEnvelope? _pendingCommand; final _pendingScoreCommandIds = {}; double? _optimisticManualScoreValue; DateTime? _lastProjectionReceivedAt; var _commandCounter = 0; var _commandFailureSerial = 0; Future refresh() async { value = value.copyWith(connectionLost: false); try { await _nativeClient.requestCapabilityRefresh(); await _nativeClient.requestResync(); } on PlatformException { value = value.copyWith(connectionLost: true); unawaited(HapticFeedback.heavyImpact()); } } Future sendPrimaryAction() async { final action = value.projection.primaryAction; final command = switch (action) { WatchPrimaryAction.none => null, WatchPrimaryAction.startCurrentExercise => WatchCommandType.startCurrentExercise, WatchPrimaryAction.pauseSession => WatchCommandType.pauseSession, WatchPrimaryAction.resumeSession => WatchCommandType.resumeSession, WatchPrimaryAction.startPreparedTimedStep => WatchCommandType.startPreparedTimedStep, WatchPrimaryAction.skipCurrentRest => WatchCommandType.skipCurrentRest, }; if (command == null) { await refresh(); return; } await _sendCommand(command); } Future sendSecondaryAction(WatchSecondaryAction action) { final command = switch (action) { WatchSecondaryAction.skipCurrentStep => WatchCommandType.skipCurrentStep, WatchSecondaryAction.skipCurrentPassage => WatchCommandType.skipCurrentPassage, WatchSecondaryAction.finishCurrentSet => WatchCommandType.finishCurrentSet, WatchSecondaryAction.skipCurrentSet => WatchCommandType.skipCurrentSet, WatchSecondaryAction.skipCurrentRest => WatchCommandType.skipCurrentRest, }; return _sendCommand(command); } Future incrementScore() { return _sendScoreCommand(WatchCommandType.incrementScore, 1); } Future decrementScore() { return _sendScoreCommand(WatchCommandType.decrementScore, -1); } Future completeCurrentStep() { return _sendCommand(WatchCommandType.completeCurrentStep); } @override void dispose() { _waitingTimer?.cancel(); _commandTimeoutTimer?.cancel(); _scoreWaitingTimer?.cancel(); _scoreCommandTimeoutTimer?.cancel(); _freshnessTimer?.cancel(); _projectionExpiryTimer?.cancel(); _commandFailureClearTimer?.cancel(); for (final subscription in _subscriptions) { unawaited(subscription.cancel()); } super.dispose(); } Future _sendCommand(WatchCommandType type) async { if (!value.actionsEnabled || (_requiresActiveSession(type) && value.projection.deviceSessionId.isEmpty)) { return; } final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; final command = WatchCommandEnvelope( commandId: 'watch-$nowMs-${_commandCounter++}', type: type, sessionId: value.projection.deviceSessionId, expectedRevision: value.projection.revision, sentAtEpochMs: nowMs, ); _pendingCommand = command; value = value.copyWith( commandPending: true, waitingForPhone: false, timerTogglePending: _isTimerToggleCommand(type), connectionLost: false, ); _waitingTimer?.cancel(); _commandTimeoutTimer?.cancel(); _waitingTimer = Timer(_waitingThreshold, () { value = value.copyWith(waitingForPhone: true); }); _commandTimeoutTimer = Timer(_commandTimeout, () { _pendingCommand = null; value = value.copyWith( commandPending: false, waitingForPhone: false, timerTogglePending: false, connectionLost: true, ); unawaited(HapticFeedback.heavyImpact()); }); try { await _nativeClient.sendCommand(command); } on PlatformException { _pendingCommand = null; _clearCommandTimers(); value = value.copyWith( commandPending: false, waitingForPhone: false, timerTogglePending: false, connectionLost: true, ); unawaited(HapticFeedback.heavyImpact()); } } Future _sendScoreCommand(WatchCommandType type, int delta) async { final projection = value.projection; if (value.connectionLost || !projection.phoneReachable || !projection.hasManualScore || projection.deviceSessionId.isEmpty) { return; } final current = _optimisticManualScoreValue ?? projection.currentManualScoreValue ?? 0; final next = (current + delta).clamp(0, double.infinity).toDouble(); if (next == current) { return; } final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; final command = WatchCommandEnvelope( commandId: 'watch-$nowMs-${_commandCounter++}', type: type, sessionId: projection.deviceSessionId, expectedRevision: projection.revision, sentAtEpochMs: nowMs, ); _pendingScoreCommandIds.add(command.commandId); _optimisticManualScoreValue = next; value = value.copyWith( scoreCommandPending: true, scoreWaitingForPhone: false, optimisticManualScoreValue: next, connectionLost: false, ); _scoreWaitingTimer?.cancel(); _scoreCommandTimeoutTimer?.cancel(); _scoreWaitingTimer = Timer(_waitingThreshold, () { value = value.copyWith(scoreWaitingForPhone: true); }); _scoreCommandTimeoutTimer = Timer(_commandTimeout, () { value = value.copyWith(scoreWaitingForPhone: true); unawaited(_nativeClient.requestResync()); }); try { await _nativeClient.sendCommand(command); unawaited(HapticFeedback.selectionClick()); } on PlatformException { _pendingScoreCommandIds.remove(command.commandId); _clearScorePending(recalibrate: true); value = value.copyWith(connectionLost: true); unawaited(HapticFeedback.heavyImpact()); } } void _handleProjection(WatchSessionProjection projection) { final previousProjection = value.projection; _lastProjectionReceivedAt = DateTime.now(); _scheduleProjectionExpiry(projection); _pendingCommand = null; _clearCommandTimers(); _syncScorePendingFromProjection(projection); value = WatchSessionUiState( projection: projection, timerTogglePending: false, scoreCommandPending: _pendingScoreCommandIds.isNotEmpty, scoreWaitingForPhone: _pendingScoreCommandIds.isNotEmpty && value.scoreWaitingForPhone, optimisticManualScoreValue: _optimisticManualScoreValue, commandFailureMessage: value.commandFailureMessage, commandFailureSerial: value.commandFailureSerial, lastAck: value.lastAck, sensorSample: projection.deviceSessionId.isEmpty ? null : value.sensorSample?.sessionId == projection.deviceSessionId ? value.sensorSample : null, ); _triggerProjectionHaptic(previousProjection, projection); } void _handleSensorSample(WatchSensorSample sample) { final sessionId = sample.sessionId.trim(); if (sessionId.isEmpty || sessionId != value.projection.deviceSessionId) { return; } final hasMetric = sample.heartRateBpm != null || sample.distanceMeters != null || sample.caloriesKcal != null; if (!hasMetric) { return; } value = value.copyWith(sensorSample: sample); } void _handleAck(WatchCommandAckEvent ack) { if (_pendingScoreCommandIds.remove(ack.commandId)) { value = value.copyWith(lastAck: ack); unawaited(HapticFeedback.lightImpact()); if (_isRejected(ack.status)) { _clearScorePending(recalibrate: true); unawaited(_nativeClient.requestResync()); } return; } if (_pendingCommand?.commandId != ack.commandId) { value = value.copyWith(lastAck: ack); return; } _waitingTimer?.cancel(); value = value.copyWith( waitingForPhone: false, connectionLost: false, lastAck: ack, ); unawaited(HapticFeedback.lightImpact()); if (_isRejected(ack.status)) { final failedType = _pendingCommand?.type; _pendingCommand = null; _clearCommandTimers(); _publishCommandFailure(_commandFailureMessageFor(failedType)); unawaited(_nativeClient.requestResync()); } } void _handleAlert(WatchAlertEnvelope alert) { final alertId = alert.alertId.trim(); final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; if (alertId.isEmpty || !_handledAlertIds.add(alertId) || alert.sessionId != value.projection.deviceSessionId || (alert.expiresAtEpochMs > 0 && nowMs > alert.expiresAtEpochMs)) { return; } if (_handledAlertIds.length > 64) { _handledAlertIds.remove(_handledAlertIds.first); } switch (alert.pattern) { case WatchAlertPattern.countdownTick: unawaited(HapticFeedback.selectionClick()); case WatchAlertPattern.timerFinished: _triggerTimerFinishedHaptic(); } } void _handleConnectionEvent(WatchBridgeConnectionEvent event) { value = value.copyWith(connectionLost: !event.isReachable); if (event.isReachable || event.requestsResync) { unawaited(_nativeClient.requestResync()); } } void _syncFreshnessState() { final receivedAt = _lastProjectionReceivedAt; if (receivedAt == 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(); 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); } } 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 delayMs = _projectionTtlMs(projection); _projectionExpiryTimer = Timer(Duration(milliseconds: delayMs), () { if (value.projection.deviceSessionId.isEmpty || _pendingCommand != null || _pendingScoreCommandIds.isNotEmpty) { return; } _invalidateExpiredProjection(); }); } void _clearCommandTimers() { _waitingTimer?.cancel(); _waitingTimer = null; _commandTimeoutTimer?.cancel(); _commandTimeoutTimer = null; } void _publishCommandFailure(String message) { _commandFailureClearTimer?.cancel(); value = value.copyWith( commandPending: false, timerTogglePending: false, commandFailureMessage: message, commandFailureSerial: ++_commandFailureSerial, ); _commandFailureClearTimer = Timer(const Duration(seconds: 2), () { value = value.copyWith(clearCommandFailureMessage: true); }); } void _clearScorePending({required bool recalibrate}) { _pendingScoreCommandIds.clear(); _scoreWaitingTimer?.cancel(); _scoreWaitingTimer = null; _scoreCommandTimeoutTimer?.cancel(); _scoreCommandTimeoutTimer = null; _optimisticManualScoreValue = null; value = value.copyWith( scoreCommandPending: false, scoreWaitingForPhone: false, clearOptimisticManualScoreValue: recalibrate, ); } void _syncScorePendingFromProjection(WatchSessionProjection projection) { if (_pendingScoreCommandIds.isEmpty) { _optimisticManualScoreValue = null; _scoreWaitingTimer?.cancel(); _scoreWaitingTimer = null; _scoreCommandTimeoutTimer?.cancel(); _scoreCommandTimeoutTimer = null; return; } final optimistic = _optimisticManualScoreValue; final confirmed = projection.currentManualScoreValue; if (optimistic != null && confirmed == optimistic) { _pendingScoreCommandIds.clear(); _optimisticManualScoreValue = null; _scoreWaitingTimer?.cancel(); _scoreWaitingTimer = null; _scoreCommandTimeoutTimer?.cancel(); _scoreCommandTimeoutTimer = null; } } void _triggerProjectionHaptic( WatchSessionProjection previous, WatchSessionProjection current, ) { final phaseChanged = previous.phase != current.phase; final enteredReadyTimer = current.phase == WatchSessionPhase.nextTimerReady && previous.phase != WatchSessionPhase.nextTimerReady; final enteredRestEnd = previous.phase == WatchSessionPhase.restRunning && current.phase != WatchSessionPhase.restRunning && current.phase != WatchSessionPhase.restPaused; if (phaseChanged && (enteredReadyTimer || enteredRestEnd)) { unawaited(HapticFeedback.mediumImpact()); unawaited( Future.delayed(const Duration(milliseconds: 120), () { return HapticFeedback.mediumImpact(); }), ); } } void _triggerTimerFinishedHaptic() { unawaited(HapticFeedback.heavyImpact()); unawaited( Future.delayed(const Duration(milliseconds: 140), () { return HapticFeedback.heavyImpact(); }), ); unawaited( Future.delayed(const Duration(milliseconds: 320), () { return HapticFeedback.heavyImpact(); }), ); } } int _projectionTtlMs(WatchSessionProjection projection) { final projectedAtEpochMs = projection.projectedAtEpochMs; final expiresAtEpochMs = projection.expiresAtEpochMs; if (projectedAtEpochMs > 0 && expiresAtEpochMs > projectedAtEpochMs) { return expiresAtEpochMs - projectedAtEpochMs; } return const Duration(seconds: 12).inMilliseconds; } bool _isRejected(WatchCommandAck ack) { return switch (ack) { WatchCommandAck.accepted || WatchCommandAck.acceptedNoOp => false, _ => true, }; } String _commandFailureMessageFor(WatchCommandType? type) { return switch (type) { WatchCommandType.finishCurrentSet || WatchCommandType.skipCurrentSet => 'Série non modifiée', _ => 'Commande non appliquée', }; } bool _isTimerToggleCommand(WatchCommandType type) { return switch (type) { WatchCommandType.startCurrentExercise || WatchCommandType.startPreparedTimedStep || WatchCommandType.pauseSession || WatchCommandType.resumeSession => true, _ => false, }; } bool _requiresActiveSession(WatchCommandType type) { return true; } WatchSessionProjection _initialProjection() { final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; return WatchSessionProjection( deviceSessionId: '', revision: 0, 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, seriesTotal: 0, exerciseName: '', primaryAction: WatchPrimaryAction.none, statusLabel: 'Téléphone indisponible', ); }