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.connectionLost = false, this.staleProjection = false, this.lastAck, }); final WatchSessionProjection projection; final bool commandPending; final bool waitingForPhone; final bool connectionLost; final bool staleProjection; final WatchCommandAckEvent? lastAck; bool get actionsEnabled => !commandPending && !connectionLost; WatchSessionUiState copyWith({ WatchSessionProjection? projection, bool? commandPending, bool? waitingForPhone, bool? connectionLost, bool? staleProjection, WatchCommandAckEvent? lastAck, }) { return WatchSessionUiState( projection: projection ?? this.projection, commandPending: commandPending ?? this.commandPending, waitingForPhone: waitingForPhone ?? this.waitingForPhone, connectionLost: connectionLost ?? this.connectionLost, staleProjection: staleProjection ?? this.staleProjection, lastAck: lastAck ?? this.lastAck, ); } } 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.acks.listen(_handleAck)); _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 = >[]; Timer? _waitingTimer; Timer? _commandTimeoutTimer; Timer? _freshnessTimer; WatchCommandEnvelope? _pendingCommand; DateTime? _lastProjectionReceivedAt; var _commandCounter = 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); } @override void dispose() { _waitingTimer?.cancel(); _commandTimeoutTimer?.cancel(); _freshnessTimer?.cancel(); for (final subscription in _subscriptions) { unawaited(subscription.cancel()); } super.dispose(); } Future _sendCommand(WatchCommandType type) async { if (!value.actionsEnabled || 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, 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, connectionLost: true, ); unawaited(HapticFeedback.heavyImpact()); }); try { await _nativeClient.sendCommand(command); } on PlatformException { _pendingCommand = null; _clearCommandTimers(); value = value.copyWith( commandPending: false, waitingForPhone: false, connectionLost: true, ); unawaited(HapticFeedback.heavyImpact()); } } void _handleProjection(WatchSessionProjection projection) { final previousProjection = value.projection; _lastProjectionReceivedAt = DateTime.now(); _pendingCommand = null; _clearCommandTimers(); value = WatchSessionUiState( projection: projection, lastAck: value.lastAck, ); _triggerProjectionHaptic(previousProjection, projection); } void _handleAck(WatchCommandAckEvent ack) { 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)) { _pendingCommand = null; _clearCommandTimers(); value = value.copyWith(commandPending: false); unawaited(_nativeClient.requestResync()); } } 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 age = DateTime.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 _clearCommandTimers() { _waitingTimer?.cancel(); _waitingTimer = null; _commandTimeoutTimer?.cancel(); _commandTimeoutTimer = 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(); })); } } } bool _isRejected(WatchCommandAck ack) { return switch (ack) { WatchCommandAck.accepted || WatchCommandAck.acceptedNoOp => false, _ => true, }; } WatchSessionProjection _initialProjection() { return WatchSessionProjection( deviceSessionId: '', revision: 0, projectedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch, phase: WatchSessionPhase.noActiveSession, phoneReachable: false, seriesIndex: 0, seriesTotal: 0, exerciseName: '', primaryAction: WatchPrimaryAction.none, statusLabel: 'Téléphone indisponible', ); }