import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'core/providers/bt_provider.dart'; import 'core/providers/sensor_provider.dart'; import 'core/providers/settings_provider.dart'; import 'core/providers/theme_provider.dart'; import 'core/bluetooth/bt_poller.dart'; import 'core/protocol/sensor_state.dart'; void main() { runApp(const ProviderScope(child: HvbtApp())); } class HvbtApp extends ConsumerWidget { const HvbtApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final themeVariant = ref.watch(themeProvider); final accent = themeVariant.accentColor; return MaterialApp( title: 'HV BT Dashboard', debugShowCheckedModeBanner: false, theme: ThemeData.dark(useMaterial3: true).copyWith( colorScheme: ColorScheme.fromSeed( seedColor: accent, brightness: Brightness.dark, ), ), home: const BtPickerScreen(), ); } } // ─── Bluetooth Device Picker ────────────────────────────────────────────────── class BtPickerScreen extends ConsumerStatefulWidget { const BtPickerScreen({super.key}); @override ConsumerState createState() => _BtPickerScreenState(); } class _BtPickerScreenState extends ConsumerState { String? _error; bool _initDone = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth()); } Future _initBluetooth() async { setState(() => _error = null); // 1. 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(() { _error = 'Permissions denied: ${denied.join(", ")}\n\n' 'Go to Settings → Apps → HV BT Dashboard → Permissions ' 'and allow Bluetooth + Location, then tap Refresh.'; _initDone = true; }); return; } // 2. Auto-enable Bluetooth final btState = await FlutterBluetoothSerial.instance.state; if (btState != BluetoothState.STATE_ON) { await FlutterBluetoothSerial.instance.requestEnable(); await Future.delayed(const Duration(seconds: 1)); } setState(() => _initDone = true); ref.invalidate(pairedDevicesProvider); } Future _connect(BluetoothDevice device, EcuType ecuType) async { // Set protocol before connecting so the poller uses it ref.read(settingsProvider.notifier).setEcuType(ecuType); await ref.read(btProvider.notifier).connect(device); if (!mounted) return; final btState = ref.read(btProvider); if (btState.status == BtStatus.connected) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const LiveDataScreen()), ); } } 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.pop(ctx); _connect(device, EcuType.s300); }, child: const Text('S300'), ), TextButton( onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.kpro); }, child: const Text('KPro'), ), TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Cancel'), ), ], ), ); } @override Widget build(BuildContext context) { final btState = ref.watch(btProvider); final devicesAsync = ref.watch(pairedDevicesProvider); return Scaffold( appBar: AppBar( title: const Text('HV BT — Select Device'), actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: btState.isConnecting ? null : () { setState(() => _error = null); _initBluetooth(); }, tooltip: 'Refresh', ), ], ), body: Column( children: [ // BT connection error banner if (btState.status == BtStatus.error) _ErrorBanner( message: 'Connection failed: ${btState.error}', onDismiss: () => ref.read(btProvider.notifier).disconnect(), ), // Permission / init error banner if (_error != null) _ErrorBanner( message: _error!, onDismiss: () => setState(() => _error = null), ), Expanded( child: !_initDone ? const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), Text('Requesting permissions…'), ], ), ) : devicesAsync.when( loading: () => const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), Text('Loading paired devices…'), ], ), ), error: (e, _) => Center( child: _ErrorBanner( message: 'Failed to load devices:\n$e', onDismiss: () => ref.refresh(pairedDevicesProvider), ), ), data: (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 BT devices found.', style: TextStyle(fontSize: 16)), SizedBox(height: 8), Text( 'Pair your ECU module in Android Settings → Bluetooth, then tap Refresh.', style: TextStyle(color: Colors.grey), textAlign: TextAlign.center, ), ], ), ), ) : ListView.separated( itemCount: devices.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, i) { final device = devices[i]; final isConnecting = btState.isConnecting && btState.deviceName == (device.name ?? device.address); return ListTile( leading: Icon(Icons.bluetooth, color: isConnecting ? Colors.blue : Colors.grey), title: Text(device.name ?? 'Unknown'), 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: btState.isConnecting ? null : () => _showProtocolDialog(device), ); }, ), ), ), ], ), ); } } // ─── Live Data Screen ───────────────────────────────────────────────────────── class LiveDataScreen extends ConsumerWidget { const LiveDataScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final btState = ref.watch(btProvider); final sensorAsync = ref.watch(sensorStateProvider); final settings = ref.watch(settingsProvider); final protocol = settings.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(btState.deviceName ?? 'ECU', style: const TextStyle(fontSize: 15)), Row( children: [ Icon( btState.isConnected ? Icons.circle : Icons.hourglass_empty, size: 9, color: btState.isConnected ? Colors.green : Colors.orange, ), const SizedBox(width: 4), Text( '$protocol • frames: ${btState.frameCount} • dropped: ${btState.droppedFrames}', style: const TextStyle(fontSize: 10, color: Colors.grey), ), ], ), ], ), actions: [ IconButton( icon: const Icon(Icons.bluetooth_disabled, color: Colors.redAccent), tooltip: 'Disconnect', onPressed: () async { await ref.read(btProvider.notifier).disconnect(); if (context.mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const BtPickerScreen()), ); } }, ), ], bottom: const TabBar( tabs: [Tab(text: 'LIVE DATA'), Tab(text: 'DEBUG LOG')], ), ), body: TabBarView( children: [ // ── Tab 1: Live sensor data ── sensorAsync.when( loading: () => const _WaitingForData(), error: (e, _) => _ErrorCenter(message: e.toString()), data: (state) => _SensorGrid( state: state, frameCount: btState.frameCount, ), ), // ── Tab 2: Error/status log ── _DebugTab(btState: btState, sensorAsync: sensorAsync), ], ), ), ); } } // ─── Waiting / Error states ─────────────────────────────────────────────────── class _WaitingForData extends StatelessWidget { const _WaitingForData(); @override Widget build(BuildContext context) { return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: Colors.orange), SizedBox(height: 20), Text('Waiting for ECU response…', style: TextStyle(fontSize: 16)), SizedBox(height: 8), Text( 'Sending request every 100ms\nMake sure ECU is powered on', style: TextStyle(color: Colors.grey, fontSize: 13), textAlign: TextAlign.center, ), SizedBox(height: 24), Text('Check DEBUG LOG tab for details', style: TextStyle(color: Colors.orange, fontSize: 13)), ], ), ); } } class _ErrorCenter extends StatelessWidget { final String message; const _ErrorCenter({required this.message}); @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48, color: Colors.red), const SizedBox(height: 12), Text(message, style: const TextStyle(color: Colors.redAccent), textAlign: TextAlign.center), ], ), ), ); } } // ─── Debug Tab ──────────────────────────────────────────────────────────────── class _DebugTab extends StatelessWidget { final BtState btState; final AsyncValue sensorAsync; const _DebugTab({required this.btState, required this.sensorAsync}); @override Widget build(BuildContext context) { final lines = <_DebugLine>[]; lines.add(_DebugLine( icon: btState.isConnected ? Icons.check_circle_outline : Icons.info_outline, color: btState.isConnected ? Colors.green : Colors.orange, text: 'BT: ${btState.status.name.toUpperCase()} ' '${btState.deviceName != null ? "→ ${btState.deviceName}" : ""}', )); if (btState.error != null) { lines.add(_DebugLine( icon: Icons.error_outline, color: Colors.red, text: 'Error: ${btState.error}')); } lines.add(_DebugLine( icon: Icons.analytics_outlined, color: Colors.grey, text: 'Frames: ${btState.frameCount} Dropped: ${btState.droppedFrames} ' 'Rate: ${btState.frameCount > 0 ? "~10 Hz" : "0 Hz"}', )); sensorAsync.whenOrNull( error: (e, _) => lines.add(_DebugLine( icon: Icons.error_outline, color: Colors.red, text: 'Parse error: $e')), data: (s) => lines.add(_DebugLine( icon: Icons.check_circle_outline, color: Colors.green, text: 'Last frame: RPM=${s.rpm.toStringAsFixed(0)} ' 'ECT=${s.ect.toStringAsFixed(1)}°C ' 'BAT=${s.bat.toStringAsFixed(2)}V')), ); return ListView( padding: const EdgeInsets.all(12), children: lines .map((l) => Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(l.icon, size: 15, color: l.color), const SizedBox(width: 8), Expanded( child: Text(l.text, style: TextStyle( color: l.color.withAlpha(220), fontSize: 12)), ), ], ), )) .toList(), ); } } class _DebugLine { final IconData icon; final Color color; final String text; const _DebugLine({required this.icon, required this.color, required this.text}); } // ─── 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: [ _Tile('VSS', '${state.vss.toStringAsFixed(1)} km/h'), _Tile('MAP', '${state.map.toStringAsFixed(1)} kPa'), _Tile('TPS', '${state.tps.toStringAsFixed(1)} %'), _Tile('ECT', '${state.ect.toStringAsFixed(1)} °C'), _Tile('IAT', '${state.iat.toStringAsFixed(1)} °C'), _Tile('BAT', '${state.bat.toStringAsFixed(2)} V'), _Tile('IGN', '${state.ign.toStringAsFixed(1)} °'), _Tile('INJ', '${state.inj.toStringAsFixed(2)} ms'), _Tile('O2', state.o2.toStringAsFixed(0)), _Tile('AFR', state.afr.toStringAsFixed(2)), _Tile('ETH', '${state.eth.toStringAsFixed(0)} %'), _Tile('GEAR', state.gear.toString()), ], ), const SizedBox(height: 8), Text('Frames: $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); } } // ─── Shared UI widgets ──────────────────────────────────────────────────────── class _BigValue extends StatelessWidget { final String label, value, 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 _Tile extends StatelessWidget { final String label, value; const _Tile(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), ), ); } } 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, 10, 8, 10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.error_outline, color: Colors.redAccent, size: 18), const SizedBox(width: 8), Expanded( child: Text(message, style: const TextStyle( color: Colors.redAccent, fontSize: 12))), IconButton( icon: const Icon(Icons.close, color: Colors.redAccent, size: 18), onPressed: onDismiss, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ); } }