From 6a80d8fc1fcebff10898c7f59467a52f1eabca8e Mon Sep 17 00:00:00 2001 From: HVBT Dev Date: Mon, 13 Apr 2026 20:13:32 +0530 Subject: [PATCH] =?UTF-8?q?v1.2.0=20=E2=80=94=20Phase=202:=20Riverpod=20pr?= =?UTF-8?q?oviders=20wired=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings_provider: AppSettings (ecuType, pollingInterval) StateNotifier - bt_provider: BtNotifier (disconnected/connecting/connected/error states), btServiceProvider singleton, pairedDevicesProvider FutureProvider, internal frameStream piped through StreamController - sensor_provider: sensorStateProvider StreamProvider (auto S300/KPro), latestSensorProvider convenience Provider - theme_provider: AppThemeVariant (red/green × dark/light) StateNotifier - main.dart: fully Riverpod — ProviderScope, ConsumerWidget throughout, no setState for BT state, providers own all lifecycle --- lib/core/providers/bt_provider.dart | 138 ++++ lib/core/providers/sensor_provider.dart | 29 + lib/core/providers/settings_provider.dart | 31 + lib/core/providers/theme_provider.dart | 32 + lib/main.dart | 830 +++++++++------------- pubspec.yaml | 2 +- 6 files changed, 583 insertions(+), 479 deletions(-) create mode 100644 lib/core/providers/bt_provider.dart create mode 100644 lib/core/providers/sensor_provider.dart create mode 100644 lib/core/providers/settings_provider.dart create mode 100644 lib/core/providers/theme_provider.dart diff --git a/lib/core/providers/bt_provider.dart b/lib/core/providers/bt_provider.dart new file mode 100644 index 0000000..f52e345 --- /dev/null +++ b/lib/core/providers/bt_provider.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../bluetooth/bt_poller.dart'; +import '../bluetooth/bt_service.dart'; +import 'settings_provider.dart'; + +// ─── Connection status enum ────────────────────────────────────────────────── + +enum BtStatus { disconnected, connecting, connected, error } + +// ─── State ─────────────────────────────────────────────────────────────────── + +class BtState { + final BtStatus status; + final String? deviceName; + final String? error; + final int frameCount; + final int droppedFrames; + + const BtState({ + required this.status, + this.deviceName, + this.error, + this.frameCount = 0, + this.droppedFrames = 0, + }); + + const BtState.disconnected() + : this(status: BtStatus.disconnected); + + BtState connecting(String name) => + BtState(status: BtStatus.connecting, deviceName: name); + + BtState connected(String name) => + BtState(status: BtStatus.connected, deviceName: name); + + BtState withError(String msg) => + BtState(status: BtStatus.error, deviceName: deviceName, error: msg); + + BtState withFrameStats(int frames, int dropped) => BtState( + status: status, + deviceName: deviceName, + frameCount: frames, + droppedFrames: dropped, + ); + + bool get isConnected => status == BtStatus.connected; + bool get isConnecting => status == BtStatus.connecting; +} + +// ─── Notifier ──────────────────────────────────────────────────────────────── + +class BtNotifier extends StateNotifier { + final Ref _ref; + BtPoller? _poller; + StreamSubscription? _pollSub; + final StreamController _frameController = + StreamController.broadcast(); + + BtNotifier(this._ref) : super(const BtState.disconnected()); + + /// Raw validated 128-byte frame stream — consumed by sensorStateProvider. + Stream get frameStream => _frameController.stream; + + Future connect(BluetoothDevice device) async { + state = state.connecting(device.name ?? device.address); + final service = _ref.read(btServiceProvider); + final settings = _ref.read(settingsProvider); + + try { + await service.connect(device); + + _poller = BtPoller( + service, + ecuType: settings.ecuType, + pollingInterval: settings.pollingInterval, + ); + + _pollSub = _poller!.frameStream.listen( + (frame) { + _frameController.add(frame); + state = state.withFrameStats( + _poller!.validFrames, _poller!.droppedFrames); + }, + onError: (Object e) { + _frameController.addError(e); + state = state.withError(e.toString()); + }, + onDone: () => state = state.withError('BT stream closed'), + ); + + _poller!.start(); + state = state.connected(device.name ?? device.address); + } catch (e) { + await service.disconnect(); + state = state.withError(e.toString()); + } + } + + Future disconnect() async { + await _pollSub?.cancel(); + _poller?.stop(); + _poller?.dispose(); + _poller = null; + await _ref.read(btServiceProvider).disconnect(); + state = const BtState.disconnected(); + } + + @override + void dispose() { + _pollSub?.cancel(); + _poller?.dispose(); + _frameController.close(); + super.dispose(); + } +} + +// ─── Providers ─────────────────────────────────────────────────────────────── + +/// Singleton BtService — lives as long as the app. +final btServiceProvider = Provider((ref) { + final service = BtService(); + ref.onDispose(service.dispose); + return service; +}); + +/// BT connection state notifier. +final btProvider = + StateNotifierProvider((ref) => BtNotifier(ref)); + +/// Paired BT devices list (re-fetchable via ref.refresh). +final pairedDevicesProvider = FutureProvider>((ref) { + return FlutterBluetoothSerial.instance.getBondedDevices(); +}); diff --git a/lib/core/providers/sensor_provider.dart b/lib/core/providers/sensor_provider.dart new file mode 100644 index 0000000..8c50fc8 --- /dev/null +++ b/lib/core/providers/sensor_provider.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../bluetooth/bt_poller.dart'; +import '../protocol/kpro_parser.dart'; +import '../protocol/s300_parser.dart'; +import '../protocol/sensor_state.dart'; +import 'bt_provider.dart'; +import 'settings_provider.dart'; + +/// Emits a parsed [SensorState] for every valid frame received from the ECU. +/// Automatically picks S300 or KPro parser based on [settingsProvider]. +final sensorStateProvider = StreamProvider((ref) { + final ecuType = ref.watch(settingsProvider).ecuType; + final btNotifier = ref.watch(btProvider.notifier); + + return btNotifier.frameStream.map((frame) { + if (ecuType == EcuType.s300) return parseS300(frame); + return parseKPro(frame); + }); +}); + +/// Last successfully parsed sensor state (never null after first frame). +/// Falls back to SensorState.zero() before any data arrives. +final latestSensorProvider = Provider((ref) { + return ref + .watch(sensorStateProvider) + .whenData((s) => s) + .value ?? SensorState.zero(); +}); diff --git a/lib/core/providers/settings_provider.dart b/lib/core/providers/settings_provider.dart new file mode 100644 index 0000000..a58e236 --- /dev/null +++ b/lib/core/providers/settings_provider.dart @@ -0,0 +1,31 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../bluetooth/bt_poller.dart'; + +class AppSettings { + final EcuType ecuType; + final Duration pollingInterval; + + const AppSettings({ + this.ecuType = EcuType.s300, + this.pollingInterval = const Duration(milliseconds: 100), + }); + + AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) => + AppSettings( + ecuType: ecuType ?? this.ecuType, + pollingInterval: pollingInterval ?? this.pollingInterval, + ); +} + +class SettingsNotifier extends StateNotifier { + SettingsNotifier() : super(const AppSettings()); + + void setEcuType(EcuType type) => state = state.copyWith(ecuType: type); + void setPollingInterval(Duration d) => + state = state.copyWith(pollingInterval: d); +} + +final settingsProvider = + StateNotifierProvider( + (ref) => SettingsNotifier(), +); diff --git a/lib/core/providers/theme_provider.dart b/lib/core/providers/theme_provider.dart new file mode 100644 index 0000000..c4ecb46 --- /dev/null +++ b/lib/core/providers/theme_provider.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum AppThemeVariant { redDark, redLight, greenDark, greenLight } + +class ThemeNotifier extends StateNotifier { + ThemeNotifier() : super(AppThemeVariant.redDark); + + void set(AppThemeVariant v) => state = v; +} + +final themeProvider = + StateNotifierProvider( + (ref) => ThemeNotifier(), +); + +/// Returns the accent color for the current theme variant. +extension AppThemeVariantX on AppThemeVariant { + Color get accentColor { + switch (this) { + case AppThemeVariant.redDark: + case AppThemeVariant.redLight: + return const Color(0xFFFF4444); + case AppThemeVariant.greenDark: + case AppThemeVariant.greenLight: + return const Color(0xFF00E676); + } + } + + bool get isDark => + this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark; +} diff --git a/lib/main.dart b/lib/main.dart index 9b99fb5..a4cadfd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,65 +1,66 @@ import 'dart:async'; -import 'dart:typed_data'; 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/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()); + runApp(const ProviderScope(child: HvbtApp())); } -class HvbtApp extends StatelessWidget { +class HvbtApp extends ConsumerWidget { const HvbtApp({super.key}); @override - Widget build(BuildContext context) { + 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: const Color(0xFFFF4444), + seedColor: accent, brightness: Brightness.dark, ), ), home: const BtPickerScreen(), - debugShowCheckedModeBanner: false, ); } } // ─── Bluetooth Device Picker ────────────────────────────────────────────────── -class BtPickerScreen extends StatefulWidget { +class BtPickerScreen extends ConsumerStatefulWidget { const BtPickerScreen({super.key}); @override - State createState() => _BtPickerScreenState(); + ConsumerState createState() => _BtPickerScreenState(); } -class _BtPickerScreenState extends State { - final BtService _btService = BtService(); - List _devices = []; - bool _loading = true; - String? _connectingAddress; +class _BtPickerScreenState extends ConsumerState { String? _error; + bool _initDone = false; @override void initState() { super.initState(); - _initBluetooth(); + WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth()); } Future _initBluetooth() async { - setState(() { _loading = true; _error = null; }); + setState(() => _error = null); - // 1. Request all required runtime permissions + // 1. Runtime permissions final statuses = await [ Permission.bluetoothConnect, Permission.bluetoothScan, @@ -73,60 +74,36 @@ class _BtPickerScreenState extends State { 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.'; + 'Go to Settings → Apps → HV BT Dashboard → Permissions ' + 'and allow Bluetooth + Location, then tap Refresh.'; + _initDone = true; }); return; } - // 2. Make sure Bluetooth is enabled (shows system dialog to turn it on) + // 2. Auto-enable Bluetooth 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; - }); - } + setState(() => _initDone = true); + ref.invalidate(pairedDevicesProvider); } Future _connect(BluetoothDevice device, EcuType ecuType) async { - setState(() => _connectingAddress = device.address); - try { - await _btService.connect(device); - if (!mounted) return; + // 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: (_) => LiveDataScreen( - btService: _btService, - deviceName: device.name ?? device.address, - ecuType: ecuType, - ), - ), + MaterialPageRoute(builder: (_) => const LiveDataScreen()), ); - } 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.'; - }); } } @@ -138,21 +115,15 @@ class _BtPickerScreenState extends State { content: const Text('Select ECU protocol:'), actions: [ TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - _connect(device, EcuType.s300); - }, + onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.s300); }, child: const Text('S300'), ), TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - _connect(device, EcuType.kpro); - }, + onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.kpro); }, child: const Text('KPro'), ), TextButton( - onPressed: () => Navigator.of(ctx).pop(), + onPressed: () => Navigator.pop(ctx), child: const Text('Cancel'), ), ], @@ -162,20 +133,33 @@ class _BtPickerScreenState extends State { @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: _loading ? null : _initBluetooth, + onPressed: btState.isConnecting + ? null + : () { setState(() => _error = null); _initBluetooth(); }, tooltip: 'Refresh', ), ], ), body: Column( children: [ - // Error banner + // 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!, @@ -183,78 +167,87 @@ class _BtPickerScreenState extends State { ), Expanded( - child: _loading + child: !_initDone ? const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), - Text('Loading paired devices…'), + Text('Requesting permissions…'), ], ), ) - : _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), - ); - }, + : 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), + ); + }, + ), + ), ), ], ), @@ -264,138 +257,15 @@ class _BtPickerScreenState extends State { // ─── 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, - }); +class LiveDataScreen extends ConsumerWidget { + const LiveDataScreen({super.key}); @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'; + 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, @@ -406,20 +276,19 @@ class _LiveDataScreenState extends State { title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.deviceName, + Text(btState.deviceName ?? 'ECU', style: const TextStyle(fontSize: 15)), Row( children: [ - Icon(_frameCount == 0 - ? Icons.hourglass_empty - : Icons.circle, - size: 10, - color: _statusColor), + Icon( + btState.isConnected ? Icons.circle : Icons.hourglass_empty, + size: 9, + color: btState.isConnected ? Colors.green : Colors.orange, + ), const SizedBox(width: 4), Text( - '$protocol • $_statusText • dropped: $_dropped', - style: const TextStyle( - fontSize: 10, color: Colors.grey), + '$protocol • frames: ${btState.frameCount} • dropped: ${btState.droppedFrames}', + style: const TextStyle(fontSize: 10, color: Colors.grey), ), ], ), @@ -427,52 +296,36 @@ class _LiveDataScreenState extends State { ), actions: [ IconButton( - icon: const Icon(Icons.bluetooth_disabled, - color: Colors.redAccent), - onPressed: _disconnect, + 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'), - ], + 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 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: Debug Log ── - _DebugLogView(log: _log), + // ── Tab 2: Error/status log ── + _DebugTab(btState: btState, sensorAsync: sensorAsync), ], ), ), @@ -480,7 +333,135 @@ class _LiveDataScreenState extends State { } } -// ─── Sensor Grid ───────────────────────────────────────────────────────────── +// ─── 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; @@ -523,25 +504,23 @@ class _SensorGrid extends StatelessWidget { 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()), + _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 received: $frameCount', - style: const TextStyle(color: Colors.grey, fontSize: 11), - ), + Text('Frames: $frameCount', + style: const TextStyle(color: Colors.grey, fontSize: 11)), ], ), ); @@ -554,134 +533,11 @@ class _SensorGrid extends StatelessWidget { } } -// ─── 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 ────────────────────────────────────────────────────────────── +// ─── Shared UI widgets ──────────────────────────────────────────────────────── class _BigValue extends StatelessWidget { - final String label; - final String value; - final String unit; + final String label, value, unit; final Color color; - const _BigValue( {required this.label, required this.value, @@ -702,51 +558,40 @@ class _BigValue extends StatelessWidget { children: [ Text(label, style: TextStyle( - color: color, - fontSize: 12, - letterSpacing: 2, + 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)), + style: TextStyle(color: color, fontSize: 48, + fontWeight: FontWeight.bold, letterSpacing: -2)), Text(unit, - style: TextStyle( - color: color.withAlpha(160), fontSize: 12)), + style: TextStyle(color: color.withAlpha(160), fontSize: 12)), ], ), ); } } -class _SensorTile extends StatelessWidget { - final String label; - final String value; - const _SensorTile(this.label, this.value); +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), - ), + 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)), + color: Colors.grey, fontSize: 11, letterSpacing: 1)), Text(value, style: const TextStyle( - color: Colors.white, - fontSize: 14, + color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), ], ), @@ -769,18 +614,47 @@ class _FlagPill extends StatelessWidget { 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, - ), + 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, - ), + 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(), + ), + ], ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 41a8c84..a8eda0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: HV BT Automotive ECU Dashboard publish_to: 'none' -version: 1.0.1+2 +version: 1.2.0+3 environment: sdk: '>=3.0.0 <4.0.0'