import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:permission_handler/permission_handler.dart'; import 'core/bluetooth/bt_poller.dart'; import 'core/bluetooth/bt_service.dart'; import 'core/protocol/kpro_parser.dart'; import 'core/protocol/s300_parser.dart'; import 'core/protocol/sensor_state.dart'; void main() { runApp(const HvbtApp()); } class HvbtApp extends StatelessWidget { const HvbtApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'HV BT Dashboard', theme: ThemeData.dark(useMaterial3: true).copyWith( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFFFF4444), brightness: Brightness.dark, ), ), home: const BtPickerScreen(), debugShowCheckedModeBanner: false, ); } } // ─── Bluetooth Device Picker ────────────────────────────────────────────────── class BtPickerScreen extends StatefulWidget { const BtPickerScreen({super.key}); @override State createState() => _BtPickerScreenState(); } class _BtPickerScreenState extends State { final BtService _btService = BtService(); List _devices = []; bool _loading = true; String? _connectingAddress; String? _error; @override void initState() { super.initState(); _initBluetooth(); } Future _initBluetooth() async { setState(() { _loading = true; _error = null; }); // 1. Request all required runtime permissions final statuses = await [ Permission.bluetoothConnect, Permission.bluetoothScan, Permission.locationWhenInUse, ].request(); final denied = statuses.entries .where((e) => e.value.isDenied || e.value.isPermanentlyDenied) .map((e) => e.key.toString()) .toList(); if (denied.isNotEmpty) { setState(() { _loading = false; _error = 'Permissions denied: ${denied.join(", ")}\n\n' 'Go to Settings → Apps → HV BT Dashboard → Permissions and allow Bluetooth + Location, then tap Refresh.'; }); return; } // 2. Make sure Bluetooth is enabled (shows system dialog to turn it on) final btState = await FlutterBluetoothSerial.instance.state; if (btState != BluetoothState.STATE_ON) { await FlutterBluetoothSerial.instance.requestEnable(); // Give the radio a moment to come up await Future.delayed(const Duration(seconds: 1)); } await _loadDevices(); } Future _loadDevices() async { setState(() { _loading = true; _error = null; }); try { final devices = await _btService.getPairedDevices(); setState(() { _devices = devices; _loading = false; }); } catch (e) { setState(() { _error = 'Failed to load devices:\n$e\n\nMake sure Bluetooth is ON and tap Refresh.'; _loading = false; }); } } Future _connect(BluetoothDevice device, EcuType ecuType) async { setState(() => _connectingAddress = device.address); try { await _btService.connect(device); if (!mounted) return; Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => LiveDataScreen( btService: _btService, deviceName: device.name ?? device.address, ecuType: ecuType, ), ), ); } catch (e) { if (!mounted) return; setState(() { _connectingAddress = null; _error = 'Connection to ${device.name ?? device.address} failed:\n$e\n\nMake sure the device is powered on and paired.'; }); } } void _showProtocolDialog(BluetoothDevice device) { showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(device.name ?? device.address), content: const Text('Select ECU protocol:'), actions: [ TextButton( onPressed: () { Navigator.of(ctx).pop(); _connect(device, EcuType.s300); }, child: const Text('S300'), ), TextButton( onPressed: () { Navigator.of(ctx).pop(); _connect(device, EcuType.kpro); }, child: const Text('KPro'), ), TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel'), ), ], ), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('HV BT — Select Device'), actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: _loading ? null : _initBluetooth, tooltip: 'Refresh', ), ], ), body: Column( children: [ // Error banner if (_error != null) _ErrorBanner( message: _error!, onDismiss: () => setState(() => _error = null), ), Expanded( child: _loading ? const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), Text('Loading paired devices…'), ], ), ) : _devices.isEmpty ? const Center( child: Padding( padding: EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey), SizedBox(height: 16), Text( 'No paired Bluetooth devices found.', style: TextStyle(fontSize: 16), textAlign: TextAlign.center, ), SizedBox(height: 8), Text( 'Go to Android Settings → Bluetooth and pair your ECU module first, then come back and tap Refresh.', style: TextStyle(color: Colors.grey), textAlign: TextAlign.center, ), ], ), ), ) : ListView.separated( itemCount: _devices.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final device = _devices[index]; final isConnecting = _connectingAddress == device.address; return ListTile( leading: Icon( Icons.bluetooth, color: isConnecting ? Colors.blue : Colors.grey, ), title: Text(device.name ?? 'Unknown Device'), subtitle: Text(device.address), trailing: isConnecting ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2), ) : const Icon( Icons.arrow_forward_ios, size: 16, color: Colors.grey, ), onTap: isConnecting ? null : () => _showProtocolDialog(device), ); }, ), ), ], ), ); } } // ─── Live Data Screen ───────────────────────────────────────────────────────── class LiveDataScreen extends StatefulWidget { final BtService btService; final String deviceName; final EcuType ecuType; const LiveDataScreen({ super.key, required this.btService, required this.deviceName, required this.ecuType, }); @override State createState() => _LiveDataScreenState(); } class _LiveDataScreenState extends State { late final BtPoller _poller; StreamSubscription? _frameSub; SensorState? _state; int _frameCount = 0; int _dropped = 0; DateTime? _lastFrameTime; // Debug log — last 20 events shown on screen final List<_LogEntry> _log = []; @override void initState() { super.initState(); _addLog('Connecting to ${widget.deviceName}…', LogLevel.info); _addLog( 'Protocol: ${widget.ecuType == EcuType.s300 ? "S300" : "KPro"}', LogLevel.info); _poller = BtPoller( widget.btService, ecuType: widget.ecuType, pollingInterval: const Duration(milliseconds: 100), ); _poller.start(); _addLog('Poller started — sending requests every 100ms', LogLevel.info); _frameSub = _poller.frameStream.listen( (Uint8List frame) { try { final SensorState s = widget.ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame); final now = DateTime.now(); if (mounted) { setState(() { _state = s; _frameCount++; _dropped = _poller.droppedFrames; _lastFrameTime = now; }); // Log first frame and then every 100 frames if (_frameCount == 1) { _addLog('First frame received! RPM=${s.rpm.toStringAsFixed(0)}', LogLevel.ok); } else if (_frameCount % 100 == 0) { _addLog( 'Frame #$_frameCount RPM=${s.rpm.toStringAsFixed(0)} ECT=${s.ect.toStringAsFixed(1)}°C', LogLevel.info); } } } catch (e) { _addLog('Parse error: $e', LogLevel.error); } }, onError: (Object e) { _addLog('BT stream error: $e', LogLevel.error); }, onDone: () { _addLog('BT stream closed (device disconnected?)', LogLevel.error); }, ); } void _addLog(String msg, LogLevel level) { if (!mounted) return; setState(() { _log.insert( 0, _LogEntry( message: msg, level: level, time: DateTime.now(), )); if (_log.length > 20) _log.removeLast(); }); } @override void dispose() { _frameSub?.cancel(); _poller.dispose(); widget.btService.dispose(); super.dispose(); } Future _disconnect() async { _poller.stop(); await widget.btService.disconnect(); if (!mounted) return; Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const BtPickerScreen()), ); } String get _statusText { if (_frameCount == 0) return 'Waiting for data…'; final ago = _lastFrameTime == null ? '?' : '${DateTime.now().difference(_lastFrameTime!).inMilliseconds}ms ago'; return 'LIVE • last frame $ago'; } Color get _statusColor { if (_frameCount == 0) return Colors.orange; final ms = _lastFrameTime == null ? 999 : DateTime.now().difference(_lastFrameTime!).inMilliseconds; return ms < 500 ? Colors.green : Colors.orange; } @override Widget build(BuildContext context) { final protocol = widget.ecuType == EcuType.s300 ? 'S300' : 'KPro'; return DefaultTabController( length: 2, child: Scaffold( backgroundColor: const Color(0xFF0A0A0A), appBar: AppBar( backgroundColor: const Color(0xFF1A1A1A), title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(widget.deviceName, style: const TextStyle(fontSize: 15)), Row( children: [ Icon(_frameCount == 0 ? Icons.hourglass_empty : Icons.circle, size: 10, color: _statusColor), const SizedBox(width: 4), Text( '$protocol • $_statusText • dropped: $_dropped', style: const TextStyle( fontSize: 10, color: Colors.grey), ), ], ), ], ), actions: [ IconButton( icon: const Icon(Icons.bluetooth_disabled, color: Colors.redAccent), onPressed: _disconnect, tooltip: 'Disconnect', ), ], bottom: const TabBar( tabs: [ Tab(text: 'LIVE DATA'), Tab(text: 'DEBUG LOG'), ], ), ), body: TabBarView( children: [ // ── Tab 1: Live Sensor Data ── _state == null ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator( color: Colors.orange), const SizedBox(height: 20), const Text('Waiting for ECU response…', style: TextStyle(fontSize: 16)), const SizedBox(height: 8), Text( 'Sending ${protocol} request every 100ms\nMake sure ECU is powered on', style: const TextStyle( color: Colors.grey, fontSize: 13), textAlign: TextAlign.center, ), const SizedBox(height: 24), const Text( 'Check DEBUG LOG tab for details', style: TextStyle( color: Colors.orange, fontSize: 13), ), ], ), ) : _SensorGrid(state: _state!, frameCount: _frameCount), // ── Tab 2: Debug Log ── _DebugLogView(log: _log), ], ), ), ); } } // ─── Sensor Grid ───────────────────────────────────────────────────────────── class _SensorGrid extends StatelessWidget { final SensorState state; final int frameCount; const _SensorGrid({required this.state, required this.frameCount}); @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column( children: [ _BigValue( label: 'RPM', value: state.rpm.toStringAsFixed(0), unit: 'rpm', color: _rpmColor(state.rpm), ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 6, children: [ _FlagPill('MIL', state.mil, Colors.amber), _FlagPill('VTEC', state.vtec, Colors.blue), _FlagPill('KNOCK', state.knock, Colors.red), _FlagPill('FUEL CUT', state.fuelCut, Colors.red), _FlagPill('REV LIM', state.revLimit, Colors.orange), _FlagPill('FAN', state.fanOut, Colors.green), _FlagPill('LAUNCH', state.launch, Colors.purple), ], ), const SizedBox(height: 10), GridView.count( crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 6, crossAxisSpacing: 6, childAspectRatio: 2.4, children: [ _SensorTile('VSS', '${state.vss.toStringAsFixed(1)} km/h'), _SensorTile('MAP', '${state.map.toStringAsFixed(1)} kPa'), _SensorTile('TPS', '${state.tps.toStringAsFixed(1)} %'), _SensorTile('ECT', '${state.ect.toStringAsFixed(1)} °C'), _SensorTile('IAT', '${state.iat.toStringAsFixed(1)} °C'), _SensorTile('BAT', '${state.bat.toStringAsFixed(2)} V'), _SensorTile('IGN', '${state.ign.toStringAsFixed(1)} °'), _SensorTile('INJ', '${state.inj.toStringAsFixed(2)} ms'), _SensorTile('O2', state.o2.toStringAsFixed(0)), _SensorTile('AFR', state.afr.toStringAsFixed(2)), _SensorTile('ETH', '${state.eth.toStringAsFixed(0)} %'), _SensorTile('GEAR', state.gear.toString()), ], ), const SizedBox(height: 8), Text( 'Frames received: $frameCount', style: const TextStyle(color: Colors.grey, fontSize: 11), ), ], ), ); } Color _rpmColor(double rpm) { if (rpm > 7000) return Colors.red; if (rpm > 5500) return Colors.orange; return const Color(0xFF00E676); } } // ─── Debug Log View ─────────────────────────────────────────────────────────── class _DebugLogView extends StatelessWidget { final List<_LogEntry> log; const _DebugLogView({required this.log}); @override Widget build(BuildContext context) { if (log.isEmpty) { return const Center( child: Text('No log entries yet.', style: TextStyle(color: Colors.grey)), ); } return ListView.builder( padding: const EdgeInsets.all(8), itemCount: log.length, itemBuilder: (context, index) { final entry = log[index]; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _ts(entry.time), style: const TextStyle( color: Colors.grey, fontSize: 11, fontFamily: 'monospace'), ), const SizedBox(width: 8), Icon( entry.level == LogLevel.error ? Icons.error_outline : entry.level == LogLevel.ok ? Icons.check_circle_outline : Icons.info_outline, size: 14, color: entry.level == LogLevel.error ? Colors.red : entry.level == LogLevel.ok ? Colors.green : Colors.grey, ), const SizedBox(width: 6), Expanded( child: Text( entry.message, style: TextStyle( fontSize: 12, color: entry.level == LogLevel.error ? Colors.redAccent : entry.level == LogLevel.ok ? Colors.greenAccent : Colors.white70, ), ), ), ], ), ); }, ); } String _ts(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; } enum LogLevel { info, ok, error } class _LogEntry { final String message; final LogLevel level; final DateTime time; const _LogEntry( {required this.message, required this.level, required this.time}); } // ─── Error Banner ───────────────────────────────────────────────────────────── class _ErrorBanner extends StatelessWidget { final String message; final VoidCallback onDismiss; const _ErrorBanner({required this.message, required this.onDismiss}); @override Widget build(BuildContext context) { return Container( width: double.infinity, color: const Color(0xFF4A0000), padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.error_outline, color: Colors.redAccent, size: 20), const SizedBox(width: 10), Expanded( child: Text( message, style: const TextStyle( color: Colors.redAccent, fontSize: 13), ), ), IconButton( icon: const Icon(Icons.close, color: Colors.redAccent, size: 20), onPressed: onDismiss, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ); } } // ─── UI helpers ────────────────────────────────────────────────────────────── class _BigValue extends StatelessWidget { final String label; final String value; final String unit; final Color color; const _BigValue( {required this.label, required this.value, required this.unit, required this.color}); @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24), decoration: BoxDecoration( color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withAlpha(120), width: 1.5), ), child: Column( children: [ Text(label, style: TextStyle( color: color, fontSize: 12, letterSpacing: 2, fontWeight: FontWeight.w600)), const SizedBox(height: 2), Text(value, style: TextStyle( color: color, fontSize: 48, fontWeight: FontWeight.bold, letterSpacing: -2)), Text(unit, style: TextStyle( color: color.withAlpha(160), fontSize: 12)), ], ), ); } } class _SensorTile extends StatelessWidget { final String label; final String value; const _SensorTile(this.label, this.value); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle( color: Colors.grey, fontSize: 11, letterSpacing: 1)), Text(value, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), ], ), ); } } class _FlagPill extends StatelessWidget { final String label; final bool active; final Color color; const _FlagPill(this.label, this.active, this.color); @override Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 120), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(20), border: Border.all( color: active ? color : Colors.grey.withAlpha(60), width: 1.5, ), ), child: Text( label, style: TextStyle( color: active ? color : Colors.grey, fontSize: 11, fontWeight: active ? FontWeight.bold : FontWeight.normal, letterSpacing: 1, ), ), ); } }