import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/media_playback.dart'; import '../../data/models/system_status.dart'; import '../../data/services/native_bridge.dart'; /// Polls the system for battery, Wi-Fi, media volume and media activity. /// /// Polling keeps the app free of long-lived broadcast receivers and works /// identically on Android 10 and 15. Timers are stopped while the launcher is /// in the background. class SystemController extends ChangeNotifier { SystemController(this._platform); final LauncherPlatform _platform; SystemStatus _status = const SystemStatus(); VolumeInfo _volume = const VolumeInfo(); Timer? _volumeTimer; Timer? _statusTimer; bool _active = false; bool _volumeBusy = false; bool _statusBusy = false; bool _musicBusy = false; bool _musicPlaying = false; bool _volumeSetting = false; bool _showVolumeDiagnostics = false; int _volumeWriteId = 0; int? _lastVolumeTarget; VolumeInfo? _lastVolumeRead; MediaPlayback _media = const MediaPlayback(); bool _disposed = false; int _lastNonZeroVolume = 0; /// How long a fresh write may take before a poll decides the read-back is /// not tracking it. Car units often answer a write a few hundred ms later. static const Duration _settleWindow = Duration(milliseconds: 1600); int? _pendingIndex; int? _pendingBaseline; DateTime? _pendingSince; int _pendingMisses = 0; bool _readBackStale = false; int? _staleIndex; SystemStatus get status => _status; VolumeInfo get volume => _volume; int get volumePercent => _volume.percent; bool get isMuted => _volume.index <= 0; bool get isVolumeFixed => !_volume.isControllable; bool get isMusicPlaying => _musicPlaying; /// True when the car reports the same level no matter what was written. /// The card still shows the requested level, marked with a question mark. bool get volumeUnconfirmed => _readBackStale; String? get volumeDiagnostics { if (!_showVolumeDiagnostics) return null; final write = _lastVolumeTarget == null ? 'none' : '${_lastVolumeTarget!} -> ${_lastVolumeRead?.index ?? '?'}'; final streams = _volume.streams.entries .map((entry) => '${entry.key} ${entry.value}') .join(' '); final state = [ 'raw ${_volume.index}/${_volume.max}', 'min ${_volume.min}', 'fixed ${_volume.fixed}', 'source ${_volume.source}', if (_volume.vendor.isNotEmpty) 'vendor ${_volume.vendor}', if (_readBackStale) 'unconfirmed', ].join(' '); return '$state\n' 'write $write\n' '${streams.isEmpty ? 'streams unavailable' : streams}'; } void toggleVolumeDiagnostics() { _showVolumeDiagnostics = !_showVolumeDiagnostics; _notify(); } MediaPlayback get media => _media; bool get hasMediaAccess => _media.hasAccess; Future start() async { if (_active) return; _active = true; await refreshAll(); _volumeTimer = Timer.periodic(const Duration(milliseconds: 900), (_) { _pollVolume(); _pollMusic(); }); _statusTimer = Timer.periodic( const Duration(seconds: 20), (_) => _pollStatus(), ); } void stop() { _active = false; _volumeTimer?.cancel(); _volumeTimer = null; _statusTimer?.cancel(); _statusTimer = null; } Future refreshAll() async { await Future.wait([_pollVolume(), _pollStatus(), _pollMusic()]); } Future _pollVolume() async { if (_volumeBusy || _volumeSetting || !_active) return; _volumeBusy = true; try { final next = await _platform.getVolume(); if (!_active) return; if (_lastVolumeTarget != null) _lastVolumeRead = next; final pending = _pendingIndex; if (pending != null) { if (next.index == pending) { _confirmVolume(next); return; } final baseline = _pendingBaseline; final since = _pendingSince; final elapsed = since == null ? _settleWindow : DateTime.now().difference(since); if (baseline != null && next.index == baseline) { if (elapsed >= _settleWindow) { _pendingMisses++; if (_pendingMisses >= 2) { _readBackStale = true; _staleIndex = baseline; } } _mergeMetadata(next); return; } // The car may still be applying an earlier step of the same gesture. if (elapsed < _settleWindow) { _mergeMetadata(next); return; } // Something else moved the volume (hardware keys, another app). _adoptVolume(next); return; } if (_readBackStale) { if (next.index == _staleIndex) { _mergeMetadata(next); return; } _readBackStale = false; _staleIndex = null; } _adoptVolume(next); } finally { _volumeBusy = false; } } bool _sameVolume(VolumeInfo a, VolumeInfo b) { return a.index == b.index && a.max == b.max && a.min == b.min && a.fixed == b.fixed && a.source == b.source && a.vendor == b.vendor && mapEquals(a.streams, b.streams); } void _clearPending() { _pendingIndex = null; _pendingBaseline = null; _pendingSince = null; _pendingMisses = 0; } void _confirmVolume(VolumeInfo next) { _clearPending(); _readBackStale = false; _staleIndex = null; _applyVolume(next); } void _adoptVolume(VolumeInfo next) { _clearPending(); _readBackStale = false; _staleIndex = null; _applyVolume(next); } void _applyVolume(VolumeInfo next) { if (next.index > 0) _lastNonZeroVolume = next.index; if (!_sameVolume(next, _volume)) { _volume = next; _notify(); } } /// Keeps the level the user chose while refreshing the metadata around it. void _mergeMetadata(VolumeInfo next) { final max = next.max <= 0 ? _volume.max : next.max; final min = next.min.clamp(0, max); final index = _volume.index.clamp(min, max); final merged = VolumeInfo( index: index, max: max, min: min, fixed: next.fixed, streams: next.streams, source: next.source, vendor: next.vendor, ); if (!_sameVolume(merged, _volume)) { _volume = merged; _notify(); } } Future _pollMusic() async { if (_musicBusy || !_active) return; _musicBusy = true; try { final media = await _platform.getMediaState(); final fallback = await _platform.isMusicPlaying(); final playing = media.hasAccess && media.hasSession ? media.playing : fallback; final changed = media != _media || playing != _musicPlaying; _media = media; _musicPlaying = playing; if (changed) _notify(); } finally { _musicBusy = false; } } Future _pollStatus() async { if (_statusBusy) return; _statusBusy = true; try { final next = await _platform.getStatus(); if (next.batteryLevel != _status.batteryLevel || next.charging != _status.charging || next.wifi != _status.wifi) { _status = next; _notify(); } } finally { _statusBusy = false; } } Future setVolume(int index) async { final max = _volume.max <= 0 ? 15 : _volume.max; final min = _volume.min.clamp(0, max); final clamped = index.clamp(min, max); if (isVolumeFixed) { _lastVolumeTarget = clamped; _lastVolumeRead = _volume; return; } final before = _volume.index; if (clamped == before && _pendingIndex == null) return; final writeId = ++_volumeWriteId; _lastVolumeTarget = clamped; if (_pendingIndex == null) { _pendingBaseline = _lastVolumeRead?.index ?? before; } _pendingIndex = clamped; _pendingSince = DateTime.now(); _pendingMisses = 0; _volumeSetting = true; _volume = _volume.copyWith(index: clamped); if (clamped > 0) _lastNonZeroVolume = clamped; _notify(); try { await _platform.setVolume(clamped); if (writeId != _volumeWriteId) return; final actual = await _platform.getVolume(); if (writeId != _volumeWriteId) return; _lastVolumeRead = actual; if (actual.index == clamped) { _confirmVolume(actual); } else { // The car has not reported the new level yet. Keep what the user set; // the next polls either confirm it or mark it unconfirmed instead of // snapping the slider back to the old value. _mergeMetadata(actual); } } finally { if (writeId == _volumeWriteId) _volumeSetting = false; } } Future adjustVolume(int delta) => setVolume(_volume.index + delta); Future openMediaAccessSettings() { return _platform.openNotificationAccessSettings(); } /// Sends a pause key immediately. The next poll reconciles the real state. Future pauseMusic() async { try { await _platform.sendMediaKey('pause'); } catch (_) { // Media keys are best-effort; the next poll still reflects reality. } if (_musicPlaying) { _musicPlaying = false; _notify(); } } /// Tries to resume a paused session. Returns false when nothing started, so /// the caller can open the configured music app instead. Future playMusic() async { try { final sent = await _platform.sendMediaKey('play'); if (!sent) return false; await Future.delayed(const Duration(milliseconds: 450)); final media = await _platform.getMediaState(); final fallback = await _platform.isMusicPlaying(); final playing = media.hasAccess && media.hasSession ? media.playing : fallback; final changed = media != _media || playing != _musicPlaying; _media = media; _musicPlaying = playing; if (changed) _notify(); return playing; } catch (_) { return false; } } Future toggleMute() async { if (isVolumeFixed) return; if (_volume.index > 0) { _lastNonZeroVolume = _volume.index; await setVolume(0); } else { final restore = _lastNonZeroVolume > 0 ? _lastNonZeroVolume : (_volume.max <= 0 ? 1 : (_volume.max / 2).round().clamp(1, _volume.max)); await setVolume(restore); } } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; stop(); super.dispose(); } }