import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/bluetooth_device_info.dart'; import '../../data/models/obd_state.dart'; import '../../data/repositories/settings_repository.dart'; import '../../data/services/telemetry_bridge.dart'; import 'obd_economy.dart'; /// Connection lifecycle and rolling economy for the optional ELM327 adapter. /// /// Bluetooth Classic SPP only; the adapter must already be paired in Android /// Settings. The native side queues the read-only PID requests. class ObdController extends ChangeNotifier { ObdController(this._platform, this._settings, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now; final TelemetryPlatform _platform; final SettingsRepository _settings; final DateTime Function() _clock; final ObdEconomy _economy = ObdEconomy(); String? _selectedAddress; List _devices = const []; bool _loadingDevices = false; String? _devicesError; bool _needsBluetoothPermission = false; ObdState _state = const ObdState(); String? _localError; Timer? _pollTimer; bool _active = false; bool _polling = false; bool _disposed = false; int? _lastEconomyTimestamp; String? get selectedAddress => _selectedAddress; List get devices => _devices; bool get loadingDevices => _loadingDevices; String? get devicesError => _devicesError; bool get needsBluetoothPermission => _needsBluetoothPermission; bool get connected => _state.connected; bool get connecting => _state.connecting; bool get hasConfiguredAdapter => _selectedAddress != null; String? get error { if (_localError != null) return _localError; if (_state.connected) return null; return _state.error; } String get statusLabel { if (_loadingDevices) return 'Checking paired devices…'; if (_needsBluetoothPermission) return 'Bluetooth permission needed'; if (connecting) return 'Connecting…'; if (connected) { return isFresh ? 'Connected' : 'Connected — waiting for data'; } if (error != null) return 'Not connected — $error'; if (_selectedAddress == null) return 'Off'; return 'Not connected'; } bool get isFresh { if (!_state.connected || _state.updatedAt <= 0) return false; final age = _clock().millisecondsSinceEpoch - _state.updatedAt; return age >= 0 && age < 5000; } bool get hasTelemetry => isFresh && (_state.speedKmh != null || _state.throttlePercent != null); double? get speedKmh => isFresh ? _state.speedKmh : null; double? get throttlePercent => isFresh ? _state.throttlePercent : null; double? get ambientCelsius => _state.connected && isFresh ? _state.ambientCelsius : null; double? get economyL100 => isFresh ? _economy.litresPer100Km : null; void initialize() { _selectedAddress = _settings.obdAdapterAddress; } Future start() async { if (_active) return; _active = true; if (_selectedAddress != null) { await _connect(); } _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll()); _notify(); } void stop() { _active = false; _pollTimer?.cancel(); _pollTimer = null; _lastEconomyTimestamp = null; _economy.reset(); _state = const ObdState(); unawaited(_platform.disconnectObd()); _notify(); } Future selectAdapter(String? address) async { if (_selectedAddress == address) return; _selectedAddress = address; _localError = null; _lastEconomyTimestamp = null; _economy.reset(); _state = const ObdState(); await _settings.setObdAdapterAddress(address); await _platform.disconnectObd(); if (_active && address != null) { await _connect(); } _notify(); } /// Lists bonded devices. Pass [requestPermission] only when the user asked to /// find adapters; the Settings card can show a dedicated allow button. Future refreshDevices({bool requestPermission = false}) async { if (_loadingDevices) return false; _loadingDevices = true; _devicesError = null; _notify(); try { if (requestPermission) { final granted = await _platform.ensureBluetoothPermission(); if (!granted) { _needsBluetoothPermission = true; _devicesError = 'Bluetooth permission was denied.'; return false; } } final enabled = await _platform.isBluetoothEnabled(); if (!enabled) { _needsBluetoothPermission = false; _devicesError = 'Bluetooth is turned off.'; return false; } final found = await _platform.listBondedDevices(); _devices = List.unmodifiable(found); _needsBluetoothPermission = false; return true; } on TelemetryException catch (problem) { _needsBluetoothPermission = problem.message.toLowerCase().contains( 'permission', ); _devicesError = problem.message; return false; } finally { _loadingDevices = false; _notify(); } } Future _connect() async { final address = _selectedAddress; if (address == null) return; _localError = null; try { await _platform.connectObd(address); } on TelemetryException catch (problem) { _localError = problem.message; } catch (_) { _localError = 'Could not connect to the OBD adapter.'; } _notify(); } Future _poll() async { if (!_active || _polling) return; _polling = true; try { final next = await _platform.getObdState(); final lostConnection = _state.connected && !next.connected; if (lostConnection) { _economy.reset(); _lastEconomyTimestamp = null; } _state = next; if (next.connected && next.updatedAt > 0) { if (_lastEconomyTimestamp != next.updatedAt) { _lastEconomyTimestamp = next.updatedAt; _economy.add( at: _clock(), speedKmh: next.speedKmh, mafGps: next.mafGramsPerSecond, throttlePercent: next.throttlePercent, ); } } } catch (_) { // Keep the last known state; the next poll may recover. } finally { _polling = false; _notify(); } } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; _pollTimer?.cancel(); unawaited(_platform.disconnectObd()); super.dispose(); } }