diff --git a/.gitignore b/.gitignore index 9796552..6f7242a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,14 @@ build/ # Android android/.gradle/ +android/.kotlin/ android/captures/ android/local.properties android/key.properties +android/hs_err_pid*.log +android/replay_pid*.log *.jks +*.log # IDE .idea/ diff --git a/docs/FULL_PROJECT_DOCUMENTATION.md b/docs/FULL_PROJECT_DOCUMENTATION.md new file mode 100644 index 0000000..96292db --- /dev/null +++ b/docs/FULL_PROJECT_DOCUMENTATION.md @@ -0,0 +1,630 @@ +# HV BT Dashboard - Full Project Documentation + +## 1. Simple Summary + +HV BT Dashboard is a Flutter Android app for car/ECU monitoring over Bluetooth. +̥ +In plain terms, the app connects to a paired Bluetooth ECU device, asks the ECU for live data many times per second, checks that the received data is valid, converts the raw bytes into readable vehicle values, and shows those values on dashboards, lists, graphs, fault-code screens, and saved recording screens. + +The main use case is for a driver, tuner, mechanic, or performance shop that wants to see Honda ECU data such as RPM, speed, throttle, manifold pressure, coolant temperature, intake temperature, battery voltage, fuel/ignition related readings, warning flags, and diagnostic trouble codes. + +## 2. What The App Is For + +This app is not a general car marketplace or booking app. It is an ECU dashboard and datalogging tool. + +It helps a user: + +- Connect to a Bluetooth ECU adapter. +- Choose the ECU protocol: S300 or KPro. +- Watch live vehicle sensor data. +- See important values in a large dashboard layout. +- See all supported sensor values in a list. +- Graph selected sensors over time. +- Check active diagnostic trouble codes. +- Record raw ECU frames into local storage. +- View saved recording sessions. +- Start playback controls for saved sessions. +- Change theme, ECU protocol, and polling speed. + +## 3. Main Benefits + +The business/user benefits are: + +- Faster troubleshooting: faults and live readings are visible in one app. +- Safer tuning support: important values like RPM, MAP, TPS, coolant temperature, intake temperature, AFR, trims, and battery voltage can be watched live. +- Better diagnosis: diagnostic trouble codes are decoded into readable descriptions instead of only raw bytes. +- Better review after driving: sessions can be recorded and saved locally. +- Flexible display: users can choose which sensors appear on dashboard tiles and graph panels. +- Works without a backend: data is read from Bluetooth and saved on the phone using SQLite. +- Supports two ECU families: S300 and KPro parser paths are implemented. + +## 4. Technology Stack + +The app is built with: + +- Flutter: mobile app framework. +- Dart: programming language. +- Riverpod: state management. +- flutter_bluetooth_serial: Bluetooth Classic serial communication. +- permission_handler: Android Bluetooth/location permissions. +- fl_chart: graph display. +- sqflite: local SQLite database for datalog sessions. +- path_provider and path: local file/database path handling. +- intl: date formatting for saved sessions. + +The project is currently Android-focused. The Android manifest includes Bluetooth and location permissions required for scanning/connecting on Android. + +## 5. App Startup Flow + +The app starts from `lib/main.dart`. + +Startup flow: + +1. `main()` starts the Flutter app inside a Riverpod `ProviderScope`. +2. `HvbtApp` builds a `MaterialApp`. +3. The selected theme is read from `themeProvider`. +4. The first screen is `BtPickerScreen`. + +So the first thing a user sees is not the dashboard. The user is first asked to connect to an ECU Bluetooth device. + +## 6. User Journey + +The typical user journey is: + +1. User opens the app. +2. App requests Bluetooth and location permissions. +3. App checks whether Bluetooth is turned on. +4. App loads already paired Bluetooth devices. +5. User taps the ECU Bluetooth device. +6. App asks whether the ECU protocol is S300 or KPro. +7. App connects to the selected Bluetooth device. +8. App starts polling the ECU. +9. App receives 128-byte data frames. +10. App validates each frame using NEG8 checksum. +11. App parses the frame into readable sensor values. +12. App navigates to the main dashboard. +13. User can switch between Dashboard, Sensors, Graph, and DTC tabs. +14. User can record a datalog session using the floating record button. +15. User can open saved sessions from the folder icon. +16. User can change protocol/theme/polling settings from the settings icon. + +## 7. Main Screens + +### 7.1 Bluetooth Picker Screen + +File: `lib/ui/screens/bt_picker/bt_picker_screen.dart` + +Purpose: + +- Ask for required permissions. +- Turn on Bluetooth if needed. +- Show paired Bluetooth devices. +- Let the user pick an ECU device. +- Ask the user to choose S300 or KPro. +- Start the Bluetooth connection. + +Important behavior: + +- The app only lists paired devices. The user must pair the ECU adapter in Android settings first. +- The screen requests `bluetoothConnect`, `bluetoothScan`, and `locationWhenInUse`. +- If permissions are denied, it shows a red error banner. +- On successful connection, it replaces the screen with `MainNav`. + +### 7.2 Main Navigation + +File: `lib/ui/widgets/main_nav.dart` + +Purpose: + +- Provides the main app shell after Bluetooth connection. +- Shows the app bar. +- Shows Bluetooth status. +- Shows saved session and settings buttons. +- Shows bottom navigation. +- Shows the record floating button. + +Main tabs: + +- Dashboard +- Sensors +- Graph +- DTC + +The app uses an `IndexedStack`, so tab state is kept while switching tabs. + +### 7.3 Dashboard Screen + +Files: + +- `lib/ui/screens/dashboard/dashboard_screen.dart` +- `lib/ui/screens/dashboard/dashboard_vertical.dart` +- `lib/ui/screens/dashboard/dashboard_horizontal.dart` +- `lib/ui/screens/dashboard/dashboard_slots_provider.dart` + +Purpose: + +- Show the live dashboard in a visually useful layout. +- Show a large RPM gauge. +- Show quick sensor tiles. +- Show warning/condition flags. + +Portrait layout: + +- Large RPM gauge at the top. +- Flag row below it. +- Six sensor tiles in a 2-column grid. + +Landscape layout: + +- Sensor tiles on left and right. +- Large RPM gauge in the middle. +- Flag row at the bottom. + +Default dashboard tile sensors: + +- Speed +- Manifold pressure +- Throttle pedal +- Coolant temperature +- Intake air temperature +- Battery voltage + +The user can long-press a sensor tile and choose a different sensor for that slot. + +### 7.4 Sensor List Screen + +File: `lib/ui/screens/sensor_list/sensor_list_screen.dart` + +Purpose: + +- Show all supported sensor values in a complete list. + +This is useful when the dashboard does not show a particular value, but the user still wants to see it. + +Supported analog/numeric sensors include: + +- RPM +- Speed +- Manifold pressure +- Throttle pedal +- Injector duration +- Timing advance +- Coolant temperature +- Intake air temperature +- Battery voltage +- O2 sensor +- Gear +- Ethanol +- Barometric pressure +- AFR +- Short trim +- Long trim +- Analog input 0 through Analog input 7 + +### 7.5 Graph Screen + +Files: + +- `lib/ui/screens/graph/graph_screen.dart` +- `lib/ui/screens/graph/widgets/sensor_graph_panel.dart` +- `lib/core/providers/sensor_history_provider.dart` + +Purpose: + +- Show live sensor values over time. +- Let the user choose which sensors to graph. +- Let the user choose the visible time window. + +Default graph sensors: + +- RPM +- MAP +- TPS + +The history provider stores up to 18,000 points per sensor in memory. This is live in-memory graph history, not the same thing as saved datalog storage. + +### 7.6 DTC Screen + +Files: + +- `lib/ui/screens/dtc/dtc_screen.dart` +- `lib/core/protocol/dtc_map.dart` + +Purpose: + +- Decode ECU error bytes into readable diagnostic trouble codes. +- Show whether there are active faults. + +If there are no codes, the screen shows an all-clear state. + +If codes are active, the screen lists code and description, for example `P0130 - O2 Sensor (front)`. + +S300 supports 4 error bytes in this app. KPro supports 18 error bytes in this app. + +### 7.7 Saved Sessions Screen + +Files: + +- `lib/ui/screens/datalog/datalog_list_screen.dart` +- `lib/ui/screens/datalog/datalog_playback_bar.dart` +- `lib/core/datalog/datalog_db.dart` +- `lib/core/datalog/datalog_recorder.dart` +- `lib/core/datalog/datalog_player.dart` + +Purpose: + +- Show recorded sessions. +- Show session date, ECU type, frame count, and duration. +- Delete old sessions. +- Load a saved session into playback. +- Show playback progress, play/pause/stop controls, seek slider, and speed selector. + +Important current note: + +The player has a stream that can replay saved frames, and the UI has playback controls. However, the current live sensor provider listens to the Bluetooth frame stream, not the datalog player stream. That means the saved-session playback control layer exists, but the dashboard/sensor/graph screens are not currently wired to consume playback frames as a replacement for live Bluetooth frames. + +### 7.8 Settings Screen + +File: `lib/ui/screens/settings/settings_screen.dart` + +Purpose: + +- Show current Bluetooth connection state. +- Disconnect Bluetooth. +- Choose ECU protocol. +- Choose polling interval. +- Choose theme. +- Show app information. + +Available ECU protocols: + +- S300 +- KPro + +Available polling intervals: + +- 50 ms, about 20 Hz +- 100 ms, about 10 Hz +- 200 ms, about 5 Hz + +Available themes: + +- Red - Dark +- Red - Light +- Green - Dark +- Green - Light + +## 8. Bluetooth And ECU Data Flow + +This is the most important technical flow in the app. + +Simple version: + +The app asks the ECU for data. The ECU replies with bytes. The app checks the bytes. Then the app converts them into numbers and displays them. + +Detailed flow: + +1. `BtPickerScreen` asks the user to select a paired Bluetooth device. +2. `BtNotifier.connect()` calls `BtService.connect()`. +3. `BtService` opens a Bluetooth Classic RFCOMM/SPP connection. +4. `BtPoller` starts a timer. +5. On each timer tick, `BtPoller` sends a request command to the ECU. +6. The ECU sends back raw bytes. +7. `BtPoller` collects those bytes into a buffer. +8. It looks for a frame starting with `0x1B`. +9. It extracts 128-byte frames. +10. It validates the frame using `validateFrame()`. +11. Valid frames are sent to `BtNotifier.frameStream`. +12. `sensorStateProvider` parses each frame. +13. UI screens read `latestSensorProvider` and update. + +Request bytes: + +- S300 request: `[0x1B, 0x00, 0xE5]` +- KPro request: `[0x1B, 0x01, 0xE4]` + +Frame size: + +- 128 bytes + +Default polling: + +- 100 ms, roughly 10 updates per second + +## 9. Parsing Logic + +The app has two parser files: + +- `lib/core/protocol/s300_parser.dart` +- `lib/core/protocol/kpro_parser.dart` + +Both parsers produce the same output model: `SensorState`. + +That is important because the UI does not need to know where the values came from. The UI only reads fields like `rpm`, `vss`, `map`, `tps`, `ect`, and `bat`. + +S300 and KPro use different byte offsets and formulas. The parser hides that complexity. + +Example: + +- S300 RPM is read from bytes 3 and 4. +- KPro RPM is read from bytes 2 and 3, then divided by 4. + +The result is still exposed to the UI as `state.rpm`. + +## 10. SensorState Model + +File: `lib/core/protocol/sensor_state.dart` + +`SensorState` is the central live-data model. + +It contains: + +- Numeric sensors. +- Boolean flags. +- Raw error bytes. +- Timestamp. + +Numeric examples: + +- `rpm` +- `vss` +- `map` +- `tps` +- `inj` +- `ign` +- `ect` +- `iat` +- `bat` +- `o2` +- `gear` +- `eth` +- `pa` +- `afr` +- `strim` +- `ltrim` +- `ain0` through `ain7` + +Flag examples: + +- MIL +- Fuel Cut +- FAN Out +- VTEC +- Knock +- Rev Limit +- Launch + +## 11. State Management + +The app uses Riverpod providers. + +Important providers: + +- `btProvider`: Bluetooth connection status and frame stream. +- `pairedDevicesProvider`: paired Bluetooth devices. +- `settingsProvider`: ECU type and polling interval. +- `sensorStateProvider`: parsed live sensor stream. +- `latestSensorProvider`: latest available sensor state for UI. +- `sensorHistoryProvider`: graph history. +- `dashboardSlotsProvider`: selected dashboard tile sensors. +- `recordingProvider`: datalog recording status. +- `sessionListProvider`: saved datalog sessions. +- `playbackProvider`: saved session playback state. +- `themeProvider`: selected theme. + +In simple terms, providers are the app's shared memory. Screens watch providers, and when the provider changes, the screen updates. + +## 12. Datalog Recording + +The datalog recorder saves raw validated ECU frames, not only final display values. + +Recording flow: + +1. User taps the floating record button. +2. `RecordingNotifier.startRecording()` starts a new session. +3. The recorder listens to `BtNotifier.frameStream`. +4. Every incoming frame is added to a batch. +5. Every 500 ms, the batch is written to SQLite. +6. User taps the record button again to stop. +7. The app writes the end time and frame count to the session row. +8. Saved sessions list is refreshed. + +Database file: + +- `hvbt_datalog.db` + +Database tables: + +- `sessions`: one row per recording session. +- `frames`: raw frame blobs linked to a session. + +This approach is useful because raw frames can later be replayed or re-parsed if parser logic improves. + +## 13. Local Storage + +The app uses local phone storage only. + +There is no backend API in this project. + +Stored locally: + +- Datalog sessions. +- Raw ECU frames. + +Currently in-memory only: + +- Theme selection. +- ECU protocol selection. +- Polling interval. +- Dashboard tile slot choices. +- Graph sensor choices. + +Because these settings are not persisted with shared preferences in the current implementation, they reset when the app restarts. + +## 14. Android Permissions + +The Android manifest requests: + +- `BLUETOOTH` +- `BLUETOOTH_ADMIN` +- `BLUETOOTH_CONNECT` +- `BLUETOOTH_SCAN` +- `ACCESS_FINE_LOCATION` +- `ACCESS_COARSE_LOCATION` + +The app also requests permissions at runtime from the Bluetooth picker screen. + +Why location permission appears: + +On Android, Bluetooth scanning and device discovery have historically been tied to location permission. Even though the app is not a location app, Android may require this permission for Bluetooth workflows. + +## 15. Project Folder Map + +Important folders: + +- `lib/main.dart`: app entry point. +- `lib/core/bluetooth`: Bluetooth connection and polling. +- `lib/core/protocol`: ECU parsing, checksum, DTC map, temperature table. +- `lib/core/providers`: Riverpod state providers. +- `lib/core/models`: sensor definitions and datalog session model. +- `lib/core/datalog`: SQLite database, recording, playback. +- `lib/ui/screens`: app screens. +- `lib/ui/widgets`: shared UI widgets. +- `lib/ui/theme`: colors and theme setup. +- `test`: parser, checksum, DTC, and utility tests. +- `android`: Android build and permission configuration. + +## 16. Testing + +The project has Flutter tests for important protocol logic. + +Existing tests cover: + +- S300 parser behavior. +- KPro parser behavior. +- NEG8 checksum behavior. +- Temperature table behavior. +- DTC map decoding. + +These tests are valuable because parser bugs can display wrong vehicle data. The parser layer is one of the highest-risk parts of the app. + +Run tests with: + +```powershell +flutter test +``` + +## 17. Build And Run Commands + +Run the app on a connected device in debug mode: + +```powershell +flutter run +``` + +Run the app in release mode: + +```powershell +flutter run --release +``` + +Build a release APK: + +```powershell +flutter build apk --release +``` + +Build an Android App Bundle: + +```powershell +flutter build appbundle --release +``` + +Do not use: + +```powershell +flutter run build --release +``` + +That command is wrong because Flutter treats `build` as a Dart target file. + +## 18. Current Limitations And Honest Notes + +These are not complaints. They are useful project facts for future development. + +- Saved playback is not currently wired into `sensorStateProvider`, so dashboard screens still listen to live Bluetooth frames. +- Settings are not persisted after restart, even though `shared_preferences` is included as a dependency. +- Dashboard tile choices are not persisted after restart. +- Graph selected sensors and time window are not persisted after restart. +- The app is Android-focused because it uses `flutter_bluetooth_serial` and Android Bluetooth permissions. +- The app lists already paired devices; it does not provide a full in-app Bluetooth pairing flow. +- Settings screen shows app info as `v1.4.0 - Phase 4`, while `pubspec.yaml` version is `1.3.0+4`; this should be aligned before release. + +## 19. Plain-English Explanation For Non-Technical People + +Think of the ECU as the car's engine computer. + +The app works like a translator: + +1. It connects to the ECU through Bluetooth. +2. It repeatedly asks, "What is happening right now?" +3. The ECU replies with a block of computer bytes. +4. The app checks if the reply is valid. +5. The app translates those bytes into readable values. +6. The app shows those values as gauges, lists, graphs, and warning codes. + +For example, the ECU may send bytes that mean "engine is at 3000 RPM." A normal person cannot read that raw data, so the app converts it into a clean dashboard value. + +## 20. Feature List + +Implemented user-facing features: + +- Bluetooth permission request. +- Paired Bluetooth device listing. +- Bluetooth connection to ECU adapter. +- ECU protocol selection. +- S300 polling command. +- KPro polling command. +- 128-byte frame collection. +- NEG8 checksum validation. +- S300 parser. +- KPro parser. +- Live RPM gauge. +- Portrait dashboard. +- Landscape dashboard. +- Six customizable dashboard sensor slots. +- Live flag indicators. +- Full sensor list. +- Live graph screen. +- Add/remove graph sensors. +- Select graph time window. +- DTC decoding screen. +- S300 DTC map. +- KPro DTC map. +- Local datalog recording. +- Saved datalog session list. +- Swipe-to-delete saved sessions. +- Playback controls for saved sessions. +- Polling interval settings. +- Theme settings. +- Disconnect action. +- Bluetooth status chip. + +## 21. Suggested Next Improvements + +High-value next improvements: + +- Wire datalog playback frames into the same parser/display pipeline used by live Bluetooth. +- Persist user settings using `shared_preferences`. +- Persist dashboard tile selections. +- Persist graph sensor selections. +- Add export/share for datalog sessions. +- Add CSV export for recorded frames or parsed sensor values. +- Add clearer connection lost handling and auto-reconnect behavior. +- Align app version in UI and `pubspec.yaml`. +- Add user-facing explanation for S300 vs KPro selection. +- Add tests for datalog database and recorder behavior. + +## 22. One-Line Pitch + +HV BT Dashboard turns raw Honda ECU Bluetooth data into a practical live dashboard, diagnostic tool, graphing tool, and local datalog recorder. diff --git a/lib/core/providers/bt_provider.dart b/lib/core/providers/bt_provider.dart index f52e345..1e8d5f3 100644 --- a/lib/core/providers/bt_provider.dart +++ b/lib/core/providers/bt_provider.dart @@ -20,6 +20,7 @@ class BtState { final String? error; final int frameCount; final int droppedFrames; + final Uint8List? lastFrame; const BtState({ required this.status, @@ -27,10 +28,10 @@ class BtState { this.error, this.frameCount = 0, this.droppedFrames = 0, + this.lastFrame, }); - const BtState.disconnected() - : this(status: BtStatus.disconnected); + const BtState.disconnected() : this(status: BtStatus.disconnected); BtState connecting(String name) => BtState(status: BtStatus.connecting, deviceName: name); @@ -38,14 +39,21 @@ class BtState { BtState connected(String name) => BtState(status: BtStatus.connected, deviceName: name); - BtState withError(String msg) => - BtState(status: BtStatus.error, deviceName: deviceName, error: msg); + BtState withError(String msg) => BtState( + status: BtStatus.error, + deviceName: deviceName, + error: msg, + frameCount: frameCount, + droppedFrames: droppedFrames, + lastFrame: lastFrame, + ); - BtState withFrameStats(int frames, int dropped) => BtState( + BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState( status: status, deviceName: deviceName, frameCount: frames, droppedFrames: dropped, + lastFrame: Uint8List.fromList(frame), ); bool get isConnected => status == BtStatus.connected; @@ -84,7 +92,10 @@ class BtNotifier extends StateNotifier { (frame) { _frameController.add(frame); state = state.withFrameStats( - _poller!.validFrames, _poller!.droppedFrames); + _poller!.validFrames, + _poller!.droppedFrames, + frame, + ); }, onError: (Object e) { _frameController.addError(e); diff --git a/lib/core/providers/sensor_history_provider.dart b/lib/core/providers/sensor_history_provider.dart new file mode 100644 index 0000000..dc3bb37 --- /dev/null +++ b/lib/core/providers/sensor_history_provider.dart @@ -0,0 +1,88 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/sensor_defs.dart'; +import '../protocol/sensor_state.dart'; +import 'sensor_provider.dart'; + +const _maxPoints = 18000; + +class SensorHistory { + final Map> _data = { + for (final d in sensorDefs) d.id: [], + }; + double? _t0; + + void add(SensorState state) { + final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0; + _t0 ??= tSec; + final x = tSec - _t0!; + + void push(String id, double v) { + final list = _data[id]!; + list.add(FlSpot(x, v)); + if (list.length > _maxPoints) list.removeAt(0); + } + + push('rpm', state.rpm); + push('vss', state.vss); + push('map', state.map); + push('tps', state.tps); + push('inj', state.inj); + push('ign', state.ign); + push('ect', state.ect); + push('iat', state.iat); + push('bat', state.bat); + push('o2', state.o2); + push('gear', state.gear.toDouble()); + push('eth', state.eth); + push('pa', state.pa); + push('afr', state.afr); + push('strim', state.strim); + push('ltrim', state.ltrim); + push('ain0', state.ain0); + push('ain1', state.ain1); + push('ain2', state.ain2); + push('ain3', state.ain3); + push('ain4', state.ain4); + push('ain5', state.ain5); + push('ain6', state.ain6); + push('ain7', state.ain7); + } + + List get(String id) => List.unmodifiable(_data[id] ?? []); + + void clear() { + for (final list in _data.values) { + list.clear(); + } + _t0 = null; + } +} + +class SensorHistoryNotifier extends StateNotifier { + SensorHistoryNotifier() : super(SensorHistory()); + + void addState(SensorState s) { + state.add(s); + // trigger rebuild by reassigning same object + // ignore: invalid_use_of_protected_member + state = state; + } + + void clear() { + state.clear(); + state = SensorHistory(); + } +} + +final sensorHistoryProvider = + StateNotifierProvider((ref) { + final notifier = SensorHistoryNotifier(); + + // Feed every parsed sensor state into history + ref.listen>(sensorStateProvider, (_, next) { + next.whenData(notifier.addState); + }); + + return notifier; +}); diff --git a/lib/main.dart b/lib/main.dart index a4cadfd..f797288 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,9 @@ -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'; +import 'ui/theme/app_theme.dart'; +import 'ui/screens/bt_picker/bt_picker_screen.dart'; void main() { runApp(const ProviderScope(child: HvbtApp())); @@ -22,640 +15,12 @@ class HvbtApp extends ConsumerWidget { @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, - ), - ), + theme: AppTheme.build(themeVariant), 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(), - ), - ], - ), - ); - } -} diff --git a/lib/ui/screens/bt_picker/bt_picker_screen.dart b/lib/ui/screens/bt_picker/bt_picker_screen.dart new file mode 100644 index 0000000..b2b4b21 --- /dev/null +++ b/lib/ui/screens/bt_picker/bt_picker_screen.dart @@ -0,0 +1,178 @@ +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/bluetooth/bt_poller.dart'; +import '../../../core/providers/bt_provider.dart'; +import '../../../core/providers/settings_provider.dart'; +import '../../widgets/main_nav.dart'; + +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); + + 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.'; + _initDone = true; + }); + return; + } + + 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 { + ref.read(settingsProvider.notifier).setEcuType(ecuType); + await ref.read(btProvider.notifier).connect(device); + if (!mounted) return; + if (ref.read(btProvider).isConnected) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const MainNav()), + ); + } + } + + 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('Connect to ECU'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loading ? null : _initBluetooth, + ), + ], + ), + body: Column( + children: [ + if (btState.status == BtStatus.error) + _banner('Connection failed: ${btState.error}'), + if (_error != null) _banner(_error!), + Expanded( + child: !_initDone + ? const Center(child: CircularProgressIndicator()) + : devicesAsync.when( + loading: () => const Center( + child: CircularProgressIndicator()), + error: (e, _) => + Center(child: _banner('Failed to load: $e')), + data: (devices) => devices.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No paired BT devices.\nPair your ECU in Android Settings first.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ), + ) + : ListView.separated( + itemCount: devices.length, + separatorBuilder: (_, __) => + const Divider(height: 1), + itemBuilder: (_, i) { + final d = devices[i]; + final connecting = btState.isConnecting && + btState.deviceName == + (d.name ?? d.address); + return ListTile( + leading: const Icon(Icons.bluetooth), + title: Text(d.name ?? 'Unknown'), + subtitle: Text(d.address), + trailing: connecting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2)) + : const Icon( + Icons.arrow_forward_ios, + size: 14), + onTap: connecting + ? null + : () => _showProtocolDialog(d), + ); + }, + ), + ), + ), + ], + ), + ); + } + + bool get _loading => !_initDone || ref.read(btProvider).isConnecting; + + Widget _banner(String msg) => Container( + width: double.infinity, + color: const Color(0xFF4A0000), + padding: const EdgeInsets.all(12), + child: Text(msg, + style: const TextStyle(color: Colors.redAccent, fontSize: 12)), + ); +} diff --git a/lib/ui/screens/dashboard/dashboard_horizontal.dart b/lib/ui/screens/dashboard/dashboard_horizontal.dart new file mode 100644 index 0000000..748bf67 --- /dev/null +++ b/lib/ui/screens/dashboard/dashboard_horizontal.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/sensor_provider.dart'; +import '../../../core/providers/theme_provider.dart'; +import '../../../ui/theme/app_colors.dart'; +import 'widgets/analog_gauge.dart'; +import 'widgets/flag_pill.dart'; +import 'widgets/sensor_tile.dart'; +import 'widgets/sensor_picker_sheet.dart'; +import 'dashboard_slots_provider.dart'; + +class DashboardHorizontal extends ConsumerWidget { + const DashboardHorizontal({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(latestSensorProvider); + final themeVariant = ref.watch(themeProvider); + final colors = AppColors.forTheme(themeVariant); + final slots = ref.watch(dashboardSlotsProvider); + + return Container( + color: const Color(0xFF0A0A0A), + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Left tiles (slots 0-2) + SizedBox( + width: 140, + child: Column( + children: [ + for (int i = 0; i < 3; i++) + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: SensorTile( + slotIndex: i, + sensorId: slots[i], + state: state, + accentColor: colors.accent, + onTap: () => _pickSensor(context, ref, i), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Center gauge + Expanded( + child: Center( + child: AnalogGauge( + value: state.rpm, + minValue: 0, + maxValue: 9000, + label: 'RPM', + unit: 'rpm', + accentColor: colors.accent, + ), + ), + ), + const SizedBox(width: 8), + // Right tiles (slots 3-5) + SizedBox( + width: 140, + child: Column( + children: [ + for (int i = 3; i < 6; i++) + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: SensorTile( + slotIndex: i, + sensorId: slots[i], + state: state, + accentColor: colors.accent, + onTap: () => _pickSensor(context, ref, i), + ), + ), + ), + ], + ), + ), + ], + ), + ), + // Flag pills row + Padding( + padding: const EdgeInsets.only(top: 6), + child: FlagRow(state: state, accentColor: colors.accent), + ), + ], + ), + ); + } + + void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) { + showModalBottomSheet( + context: context, + builder: (_) => SensorPickerSheet( + currentId: ref.read(dashboardSlotsProvider)[slotIndex], + onSelected: (id) { + ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id); + Navigator.pop(context); + }, + ), + ); + } +} diff --git a/lib/ui/screens/dashboard/dashboard_screen.dart b/lib/ui/screens/dashboard/dashboard_screen.dart new file mode 100644 index 0000000..24c9598 --- /dev/null +++ b/lib/ui/screens/dashboard/dashboard_screen.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dashboard_horizontal.dart'; +import 'dashboard_vertical.dart'; + +class DashboardScreen extends ConsumerWidget { + const DashboardScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return OrientationBuilder( + builder: (context, orientation) { + return orientation == Orientation.landscape + ? const DashboardHorizontal() + : const DashboardVertical(); + }, + ); + } +} diff --git a/lib/ui/screens/dashboard/dashboard_slots_provider.dart b/lib/ui/screens/dashboard/dashboard_slots_provider.dart new file mode 100644 index 0000000..d4e1a8d --- /dev/null +++ b/lib/ui/screens/dashboard/dashboard_slots_provider.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// Default sensor IDs for the 6 dashboard tile slots +const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat']; + +class DashboardSlotsNotifier extends StateNotifier> { + DashboardSlotsNotifier() : super(List.from(_defaultSlots)); + + void setSlot(int index, String sensorId) { + final next = List.from(state); + next[index] = sensorId; + state = next; + } +} + +final dashboardSlotsProvider = + StateNotifierProvider>( + (ref) => DashboardSlotsNotifier(), +); diff --git a/lib/ui/screens/dashboard/dashboard_vertical.dart b/lib/ui/screens/dashboard/dashboard_vertical.dart new file mode 100644 index 0000000..b9a1c3c --- /dev/null +++ b/lib/ui/screens/dashboard/dashboard_vertical.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/sensor_provider.dart'; +import '../../../core/providers/theme_provider.dart'; +import '../../../ui/theme/app_colors.dart'; +import 'widgets/analog_gauge.dart'; +import 'widgets/flag_pill.dart'; +import 'widgets/sensor_tile.dart'; +import 'widgets/sensor_picker_sheet.dart'; +import 'dashboard_slots_provider.dart'; + +class DashboardVertical extends ConsumerWidget { + const DashboardVertical({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(latestSensorProvider); + final themeVariant = ref.watch(themeProvider); + final colors = AppColors.forTheme(themeVariant); + final slots = ref.watch(dashboardSlotsProvider); + + return Container( + color: const Color(0xFF0A0A0A), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), + child: AnalogGauge( + value: state.rpm, + minValue: 0, + maxValue: 9000, + label: 'RPM', + unit: 'rpm', + accentColor: colors.accent, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: FlagRow(state: state, accentColor: colors.accent), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.4, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (int i = 0; i < slots.length; i++) + SensorTile( + slotIndex: i, + sensorId: slots[i], + state: state, + accentColor: colors.accent, + onTap: () => _pickSensor(context, ref, i), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) { + showModalBottomSheet( + context: context, + builder: (_) => SensorPickerSheet( + currentId: ref.read(dashboardSlotsProvider)[slotIndex], + onSelected: (id) { + ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id); + Navigator.pop(context); + }, + ), + ); + } +} diff --git a/lib/ui/screens/dashboard/widgets/analog_gauge.dart b/lib/ui/screens/dashboard/widgets/analog_gauge.dart new file mode 100644 index 0000000..949a82c --- /dev/null +++ b/lib/ui/screens/dashboard/widgets/analog_gauge.dart @@ -0,0 +1,263 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class AnalogGauge extends StatefulWidget { + final double value; + final double minValue; + final double maxValue; + final String label; + final String unit; + final Color accentColor; + + const AnalogGauge({ + super.key, + required this.value, + required this.minValue, + required this.maxValue, + required this.label, + required this.unit, + required this.accentColor, + }); + + @override + State createState() => _AnalogGaugeState(); +} + +class _AnalogGaugeState extends State + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _anim; + + double get _normalized => + ((widget.value - widget.minValue) / + (widget.maxValue - widget.minValue)) + .clamp(0.0, 1.0); + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + ); + _anim = Tween(begin: 0, end: _normalized) + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); + _ctrl.forward(); + } + + @override + void didUpdateWidget(AnalogGauge old) { + super.didUpdateWidget(old); + if (old.value != widget.value) { + _anim = Tween(begin: _anim.value, end: _normalized) + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); + _ctrl.forward(from: 0); + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1, + child: AnimatedBuilder( + animation: _anim, + builder: (_, __) => CustomPaint( + painter: _GaugePainter( + normalizedValue: _anim.value, + accentColor: widget.accentColor, + displayValue: widget.value, + unit: widget.unit, + label: widget.label, + ), + ), + ), + ); + } +} + +class _GaugePainter extends CustomPainter { + final double normalizedValue; // 0.0–1.0 + final Color accentColor; + final double displayValue; + final String unit; + final String label; + + static const double _startAngle = 135 * pi / 180; // 7:30 position + static const double _sweepAngle = 270 * pi / 180; // 270° sweep + + _GaugePainter({ + required this.normalizedValue, + required this.accentColor, + required this.displayValue, + required this.unit, + required this.label, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width * 0.42; + final rect = Rect.fromCircle(center: center, radius: radius); + + // ── Background arc ────────────────────────────────────────────── + final bgPaint = Paint() + ..color = const Color(0xFF2A2A2A) + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.06 + ..strokeCap = StrokeCap.round; + canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint); + + // ── Red zone (last 20%) ────────────────────────────────────────── + final redPaint = Paint() + ..color = Colors.red.withAlpha(120) + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.06 + ..strokeCap = StrokeCap.round; + canvas.drawArc( + rect, + _startAngle + _sweepAngle * 0.8, + _sweepAngle * 0.2, + false, + redPaint, + ); + + // ── Value arc ──────────────────────────────────────────────────── + if (normalizedValue > 0) { + final isRedZone = normalizedValue > 0.8; + final valPaint = Paint() + ..color = isRedZone ? Colors.red : accentColor + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.06 + ..strokeCap = StrokeCap.round; + canvas.drawArc( + rect, + _startAngle, + _sweepAngle * normalizedValue, + false, + valPaint, + ); + } + + // ── Tick marks ─────────────────────────────────────────────────── + _drawTicks(canvas, center, radius, size); + + // ── Needle ──────────────────────────────────────────────────────── + _drawNeedle(canvas, center, radius, size); + + // ── Center text ────────────────────────────────────────────────── + _drawCenterText(canvas, center, size); + } + + void _drawTicks(Canvas canvas, Offset center, double radius, Size size) { + final majorPaint = Paint() + ..color = Colors.white54 + ..strokeWidth = size.width * 0.012 + ..strokeCap = StrokeCap.round; + final minorPaint = Paint() + ..color = Colors.white24 + ..strokeWidth = size.width * 0.006 + ..strokeCap = StrokeCap.round; + + const totalTicks = 50; // 2% each + for (int i = 0; i <= totalTicks; i++) { + final t = i / totalTicks; + final angle = _startAngle + _sweepAngle * t; + final isMajor = i % 5 == 0; + final paint = isMajor ? majorPaint : minorPaint; + final outerR = radius - size.width * 0.01; + final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03); + canvas.drawLine( + center + Offset(cos(angle) * innerR, sin(angle) * innerR), + center + Offset(cos(angle) * outerR, sin(angle) * outerR), + paint, + ); + } + } + + void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) { + final angle = _startAngle + _sweepAngle * normalizedValue; + final tipR = radius - size.width * 0.08; + final tailR = size.width * 0.08; + + final needlePaint = Paint() + ..color = Colors.white + ..strokeWidth = size.width * 0.018 + ..strokeCap = StrokeCap.round; + + canvas.drawLine( + center - Offset(cos(angle) * tailR, sin(angle) * tailR), + center + Offset(cos(angle) * tipR, sin(angle) * tipR), + needlePaint, + ); + + // Center dot + canvas.drawCircle( + center, + size.width * 0.04, + Paint()..color = Colors.white, + ); + canvas.drawCircle( + center, + size.width * 0.025, + Paint()..color = const Color(0xFF0A0A0A), + ); + } + + void _drawCenterText(Canvas canvas, Offset center, Size size) { + // Value + final valSpan = TextSpan( + text: displayValue.toStringAsFixed(0), + style: TextStyle( + color: Colors.white, + fontSize: size.width * 0.14, + fontWeight: FontWeight.bold, + letterSpacing: -1, + ), + ); + final valPainter = TextPainter( + text: valSpan, + textDirection: TextDirection.ltr, + )..layout(); + valPainter.paint( + canvas, + center + + Offset( + -valPainter.width / 2, + size.width * 0.1, + ), + ); + + // Unit + final unitSpan = TextSpan( + text: unit, + style: TextStyle( + color: Colors.white54, + fontSize: size.width * 0.07, + ), + ); + final unitPainter = TextPainter( + text: unitSpan, + textDirection: TextDirection.ltr, + )..layout(); + unitPainter.paint( + canvas, + center + + Offset( + -unitPainter.width / 2, + size.width * 0.1 + valPainter.height + 2, + ), + ); + } + + @override + bool shouldRepaint(_GaugePainter old) => + old.normalizedValue != normalizedValue || + old.accentColor != accentColor || + old.displayValue != displayValue; +} diff --git a/lib/ui/screens/dashboard/widgets/flag_pill.dart b/lib/ui/screens/dashboard/widgets/flag_pill.dart new file mode 100644 index 0000000..38c5b0a --- /dev/null +++ b/lib/ui/screens/dashboard/widgets/flag_pill.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import '../../../../core/protocol/sensor_state.dart'; + +class FlagPill extends StatelessWidget { + final String label; + final bool active; + final Color color; + + const FlagPill({ + super.key, + required this.label, + required this.active, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 100), + 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.white12, + width: 1.5, + ), + ), + child: Text( + label, + style: TextStyle( + color: active ? color : Colors.grey, + fontSize: 11, + fontWeight: active ? FontWeight.bold : FontWeight.normal, + letterSpacing: 1, + ), + ), + ); + } +} + +/// Row of all flag pills derived from a SensorState. +class FlagRow extends StatelessWidget { + final SensorState state; + final Color accentColor; + + const FlagRow({super.key, required this.state, required this.accentColor}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.center, + children: [ + FlagPill(label: 'MIL', active: state.mil, color: Colors.amber), + FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue), + FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red), + FlagPill(label: 'FUEL CUT', active: state.fuelCut, color: Colors.red), + FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange), + FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green), + FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple), + ], + ); + } +} diff --git a/lib/ui/screens/dashboard/widgets/sensor_picker_sheet.dart b/lib/ui/screens/dashboard/widgets/sensor_picker_sheet.dart new file mode 100644 index 0000000..f82a350 --- /dev/null +++ b/lib/ui/screens/dashboard/widgets/sensor_picker_sheet.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import '../../../../core/models/sensor_defs.dart'; + +class SensorPickerSheet extends StatelessWidget { + final String currentId; + final void Function(String id) onSelected; + + const SensorPickerSheet({ + super.key, + required this.currentId, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFF1A1A1A), + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'SELECT SENSOR', + style: TextStyle( + color: Colors.white70, + fontSize: 12, + letterSpacing: 2, + fontWeight: FontWeight.w600, + ), + ), + ), + const Divider(height: 1, color: Colors.white10), + Expanded( + child: ListView.builder( + controller: scrollController, + itemCount: sensorDefs.length, + itemBuilder: (context, i) { + final def = sensorDefs[i]; + final isSelected = def.id == currentId; + return ListTile( + title: Text( + def.displayName, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + subtitle: Text( + def.unit, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.green, size: 18) + : null, + onTap: () => onSelected(def.id), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/ui/screens/dashboard/widgets/sensor_tile.dart b/lib/ui/screens/dashboard/widgets/sensor_tile.dart new file mode 100644 index 0000000..b2ff025 --- /dev/null +++ b/lib/ui/screens/dashboard/widgets/sensor_tile.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import '../../../../core/models/sensor_defs.dart'; +import '../../../../core/protocol/sensor_state.dart'; + +class SensorTile extends StatelessWidget { + final int slotIndex; + final String sensorId; + final SensorState state; + final Color accentColor; + final VoidCallback onTap; + + const SensorTile({ + super.key, + required this.slotIndex, + required this.sensorId, + required this.state, + required this.accentColor, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final def = sensorDefs.firstWhere( + (d) => d.id == sensorId, + orElse: () => sensorDefs.first, + ); + final value = _getValue(state, sensorId); + final normalized = + ((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0); + + return GestureDetector( + onLongPress: onTap, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white10), + ), + padding: const EdgeInsets.fromLTRB(10, 8, 10, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + def.displayName.toUpperCase(), + style: const TextStyle( + color: Colors.grey, + fontSize: 10, + letterSpacing: 1, + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _format(value, sensorId), + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + ), + ), + const SizedBox(width: 3), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + def.unit, + style: TextStyle(color: accentColor, fontSize: 11), + ), + ), + ], + ), + // Bar gauge + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: normalized, + minHeight: 4, + backgroundColor: Colors.white10, + valueColor: AlwaysStoppedAnimation( + normalized > 0.8 ? Colors.red : accentColor, + ), + ), + ), + ], + ), + ), + ); + } + + String _format(double v, String id) { + if (id == 'gear') return v.toStringAsFixed(0); + if (id == 'rpm') return v.toStringAsFixed(0); + if (v.abs() >= 100) return v.toStringAsFixed(1); + return v.toStringAsFixed(2); + } +} + +double _getValue(SensorState s, String id) { + switch (id) { + case 'rpm': return s.rpm; + case 'vss': return s.vss; + case 'map': return s.map; + case 'tps': return s.tps; + case 'inj': return s.inj; + case 'ign': return s.ign; + case 'ect': return s.ect; + case 'iat': return s.iat; + case 'bat': return s.bat; + case 'o2': return s.o2; + case 'gear': return s.gear.toDouble(); + case 'eth': return s.eth; + case 'pa': return s.pa; + case 'afr': return s.afr; + case 'strim': return s.strim; + case 'ltrim': return s.ltrim; + case 'ain0': return s.ain0; + case 'ain1': return s.ain1; + case 'ain2': return s.ain2; + case 'ain3': return s.ain3; + case 'ain4': return s.ain4; + case 'ain5': return s.ain5; + case 'ain6': return s.ain6; + case 'ain7': return s.ain7; + default: return 0; + } +} diff --git a/lib/ui/screens/datalog/datalog_list_screen.dart b/lib/ui/screens/datalog/datalog_list_screen.dart new file mode 100644 index 0000000..08fc743 --- /dev/null +++ b/lib/ui/screens/datalog/datalog_list_screen.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../../core/models/datalog_session.dart'; +import '../../../core/providers/datalog_provider.dart'; +import 'datalog_playback_bar.dart'; + +class DatalogListScreen extends ConsumerWidget { + const DatalogListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sessionsAsync = ref.watch(sessionListProvider); + final playback = ref.watch(playbackProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Saved Sessions')), + body: Column( + children: [ + if (playback.isActive) + DatalogPlaybackBar(state: playback), + Expanded( + child: sessionsAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Error: $e', + style: const TextStyle(color: Colors.red))), + data: (sessions) => sessions.isEmpty + ? const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.videocam_off, + size: 56, color: Colors.grey), + SizedBox(height: 12), + Text('No recorded sessions', + style: TextStyle(color: Colors.grey)), + ], + ), + ) + : ListView.separated( + itemCount: sessions.length, + separatorBuilder: (_, __) => + const Divider(height: 1, color: Colors.white10), + itemBuilder: (context, i) => + _SessionTile(session: sessions[i]), + ), + ), + ), + ], + ), + ); + } +} + +class _SessionTile extends ConsumerWidget { + final DatalogSession session; + const _SessionTile({required this.session}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fmt = DateFormat('dd MMM yyyy HH:mm'); + final isPlaying = + ref.watch(playbackProvider).sessionId == session.id; + + return Dismissible( + key: ValueKey(session.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + confirmDismiss: (_) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete session?'), + content: const Text('This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', + style: TextStyle(color: Colors.red))), + ], + ), + ); + }, + onDismissed: (_) async { + await ref + .read(datalogDbProvider) + .deleteSession(session.id!); + ref.invalidate(sessionListProvider); + }, + child: ListTile( + leading: Icon( + isPlaying ? Icons.play_circle : Icons.videocam, + color: isPlaying ? Colors.green : Colors.grey, + ), + title: Text( + fmt.format(session.startTime), + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + '${session.ecuType.toUpperCase()} • ' + '${session.frameCount} frames • ${session.durationLabel}', + style: const TextStyle(color: Colors.grey, fontSize: 11), + ), + trailing: const Icon(Icons.play_arrow, size: 18), + onTap: () async { + await ref + .read(playbackProvider.notifier) + .loadSession(session.id!); + ref.read(playbackProvider.notifier).play(); + }, + ), + ); + } +} diff --git a/lib/ui/screens/datalog/datalog_playback_bar.dart b/lib/ui/screens/datalog/datalog_playback_bar.dart new file mode 100644 index 0000000..4eafde2 --- /dev/null +++ b/lib/ui/screens/datalog/datalog_playback_bar.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/datalog/datalog_player.dart'; +import '../../../core/providers/datalog_provider.dart'; + +class DatalogPlaybackBar extends ConsumerWidget { + final PlaybackState state; + const DatalogPlaybackBar({super.key, required this.state}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(playbackProvider.notifier); + final isPlaying = state.status == PlaybackStatus.playing; + + return Container( + color: const Color(0xFF1A1A1A), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Progress slider + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + thumbShape: + const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: + const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Slider( + value: state.progress, + onChanged: (v) => + notifier.seek((v * state.total).round()), + ), + ), + // Controls row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Frame counter + Text( + '${state.position} / ${state.total}', + style: + const TextStyle(color: Colors.grey, fontSize: 11), + ), + // Play / Pause / Stop + Row( + children: [ + IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + size: 22, + ), + onPressed: isPlaying ? notifier.pause : notifier.play, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.stop, size: 22), + onPressed: () async => notifier.stop(), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), + ], + ), + // Speed selector + DropdownButton( + value: state.speed, + isDense: true, + underline: const SizedBox(), + style: const TextStyle( + color: Colors.white70, fontSize: 12), + items: const [ + DropdownMenuItem(value: 0.5, child: Text('0.5×')), + DropdownMenuItem(value: 1.0, child: Text('1×')), + DropdownMenuItem(value: 2.0, child: Text('2×')), + DropdownMenuItem(value: 5.0, child: Text('5×')), + ], + onChanged: (v) { + if (v != null) notifier.setSpeed(v); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/debug/raw_frame_debug_screen.dart b/lib/ui/screens/debug/raw_frame_debug_screen.dart new file mode 100644 index 0000000..d429e69 --- /dev/null +++ b/lib/ui/screens/debug/raw_frame_debug_screen.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/bluetooth/bt_poller.dart'; +import '../../../core/providers/bt_provider.dart'; +import '../../../core/providers/sensor_provider.dart'; +import '../../../core/providers/settings_provider.dart'; +import '../../../core/protocol/neg8.dart'; +import '../../../core/protocol/sensor_state.dart'; + +class RawFrameDebugScreen extends ConsumerWidget { + const RawFrameDebugScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bt = ref.watch(btProvider); + final settings = ref.watch(settingsProvider); + final state = ref.watch(latestSensorProvider); + final frame = bt.lastFrame; + final hex = frame == null ? '' : _hex(frame); + + return Scaffold( + appBar: AppBar( + title: const Text('ECU Frame Debug'), + actions: [ + IconButton( + tooltip: 'Copy raw frame', + icon: const Icon(Icons.copy), + onPressed: frame == null + ? null + : () async { + await Clipboard.setData(ClipboardData(text: hex)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Raw frame copied')), + ); + } + }, + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(12), + children: [ + _Section( + title: 'Connection', + rows: [ + _RowData('Protocol', settings.ecuType.name.toUpperCase()), + _RowData('Frames', bt.frameCount.toString()), + _RowData('Dropped', bt.droppedFrames.toString()), + _RowData('Last frame bytes', frame?.length.toString() ?? 'none'), + if (frame != null) + _RowData('Checksum', validateFrame(frame) ? 'valid' : 'bad'), + ], + ), + const SizedBox(height: 12), + _Section( + title: 'Current App Decode', + rows: _decodedRows(state), + ), + const SizedBox(height: 12), + if (frame == null) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No ECU frame received yet.', + style: TextStyle(color: Colors.grey), + ), + ), + ) + else ...[ + if (settings.ecuType == EcuType.s300) ...[ + _Section( + title: 'S300 Raw Candidates', + rows: _s300CandidateRows(frame), + ), + const SizedBox(height: 12), + ], + _RawHexBlock(hex: hex), + ], + ], + ), + ); + } + + List<_RowData> _decodedRows(SensorState s) => [ + _RowData('RPM', s.rpm.toStringAsFixed(0)), + _RowData('Speed', s.vss.toStringAsFixed(2)), + _RowData('MAP', s.map.toStringAsFixed(2)), + _RowData('TPS', s.tps.toStringAsFixed(2)), + _RowData('ECT', s.ect.toStringAsFixed(2)), + _RowData('IAT', s.iat.toStringAsFixed(2)), + _RowData('Battery', s.bat.toStringAsFixed(2)), + _RowData('O2', s.o2.toStringAsFixed(2)), + _RowData('AFR', s.afr.toStringAsFixed(2)), + ]; + + List<_RowData> _s300CandidateRows(Uint8List f) { + final rpmBe = _u16be(f, 3); + final rpmLe = _u16le(f, 3); + final vssBe = _u16be(f, 5); + final vssLe = _u16le(f, 5); + final mapBe = _u16be(f, 7); + final mapLe = _u16le(f, 7); + + return [ + _RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'), + _RowData('RPM @3 BE / LE', '$rpmBe / $rpmLe'), + _RowData('VSS raw @5 BE / LE', '$vssBe / $vssLe'), + _RowData('VSS current BE km/h', _s300Vss(vssBe).toStringAsFixed(2)), + _RowData('VSS alternate LE km/h', _s300Vss(vssLe).toStringAsFixed(2)), + _RowData('MAP raw @7 BE / LE', '$mapBe / $mapLe'), + _RowData('MAP /10 BE / LE', + '${(mapBe / 10).toStringAsFixed(2)} / ${(mapLe / 10).toStringAsFixed(2)}'), + _RowData('TPS raw @9', f[9].toString()), + _RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)), + _RowData('ECT raw @45', f[0x2D].toString()), + _RowData('IAT raw @46', f[0x2E].toString()), + _RowData('BAT raw @48', f[0x30].toString()), + _RowData('ERR bytes @49-52', + '${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'), + ]; + } + + static int _u16be(Uint8List f, int o) => (f[o] << 8) | f[o + 1]; + static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8); + static String _b(Uint8List f, int o) => + f[o].toRadixString(16).padLeft(2, '0'); + + static double _s300Vss(int raw) { + return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw; + } + + static double _s300Tps(int raw) { + return raw < 25 ? 0.0 : raw * 51.0 / 46.0; + } + + static String _hex(Uint8List frame) { + final parts = []; + for (var i = 0; i < frame.length; i++) { + if (i > 0 && i % 16 == 0) parts.add('\n'); + parts.add(frame[i].toRadixString(16).padLeft(2, '0')); + } + return parts.join(' '); + } +} + +class _Section extends StatelessWidget { + final String title; + final List<_RowData> rows; + + const _Section({required this.title, required this.rows}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 8), + for (final row in rows) _InfoRow(row), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final _RowData row; + + const _InfoRow(this.row); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Expanded( + child: Text( + row.label, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Text( + row.value, + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _RawHexBlock extends StatelessWidget { + final String hex; + + const _RawHexBlock({required this.hex}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + hex, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ), + ); + } +} + +class _RowData { + final String label; + final String value; + + const _RowData(this.label, this.value); +} diff --git a/lib/ui/screens/dtc/dtc_screen.dart b/lib/ui/screens/dtc/dtc_screen.dart new file mode 100644 index 0000000..dba7906 --- /dev/null +++ b/lib/ui/screens/dtc/dtc_screen.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/protocol/dtc_map.dart'; +import '../../../core/providers/sensor_provider.dart'; +import '../../../core/providers/settings_provider.dart'; +import '../../../core/bluetooth/bt_poller.dart'; +import 'widgets/dtc_row.dart'; + +class DtcScreen extends ConsumerWidget { + const DtcScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(latestSensorProvider); + final ecuType = ref.watch(settingsProvider).ecuType; + + final dtcMap = + ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap; + final active = decodeDtcs(state.errBytes, dtcMap); + + return Column( + children: [ + // Header bar + Container( + color: const Color(0xFF1A1A1A), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon( + active.isEmpty + ? Icons.check_circle_outline + : Icons.warning_amber_rounded, + color: active.isEmpty ? Colors.green : Colors.amber, + size: 18, + ), + const SizedBox(width: 8), + Text( + active.isEmpty + ? 'No active fault codes' + : '${active.length} active DTC${active.length > 1 ? "s" : ""}', + style: TextStyle( + color: active.isEmpty ? Colors.green : Colors.amber, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + ecuType == EcuType.s300 ? 'S300' : 'KPro', + style: + const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + const Divider(height: 1, color: Colors.white10), + + // DTC list + Expanded( + child: active.isEmpty + ? const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, + size: 56, color: Colors.green), + SizedBox(height: 12), + Text('All clear — no fault codes', + style: TextStyle( + color: Colors.grey, fontSize: 15)), + ], + ), + ) + : ListView.separated( + itemCount: active.length, + separatorBuilder: (_, __) => + const Divider(height: 1, color: Colors.white10), + itemBuilder: (context, i) { + final full = active[i]; // "P0130 — O2 Sensor (front)" + final parts = full.split(' — '); + final code = parts.first.trim(); + final desc = + parts.length > 1 ? parts.sublist(1).join(' — ') : ''; + return DtcRow(code: code, description: desc); + }, + ), + ), + ], + ); + } +} diff --git a/lib/ui/screens/dtc/widgets/dtc_row.dart b/lib/ui/screens/dtc/widgets/dtc_row.dart new file mode 100644 index 0000000..2ac58f3 --- /dev/null +++ b/lib/ui/screens/dtc/widgets/dtc_row.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class DtcRow extends StatelessWidget { + final String code; + final String description; + + const DtcRow({super.key, required this.code, required this.description}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red.withAlpha(30), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.red.withAlpha(100)), + ), + child: Text( + code, + style: const TextStyle( + color: Colors.redAccent, + fontSize: 12, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + description, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/graph/graph_screen.dart b/lib/ui/screens/graph/graph_screen.dart new file mode 100644 index 0000000..8e10ca4 --- /dev/null +++ b/lib/ui/screens/graph/graph_screen.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/models/sensor_defs.dart'; +import '../../../core/providers/sensor_history_provider.dart'; +import '../../../core/providers/theme_provider.dart'; +import '../../../ui/theme/app_colors.dart'; +import 'widgets/graph_time_selector.dart'; +import 'widgets/sensor_graph_panel.dart'; + +final _graphWindowProvider = StateProvider((ref) => 60); +final _graphSensorsProvider = StateProvider>( + (ref) => ['rpm', 'map', 'tps'], +); + +class GraphScreen extends ConsumerWidget { + const GraphScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final history = ref.watch(sensorHistoryProvider); + final colors = AppColors.forTheme(ref.watch(themeProvider)); + final window = ref.watch(_graphWindowProvider); + final sensors = ref.watch(_graphSensorsProvider); + + return Column( + children: [ + // Time selector bar + Container( + color: const Color(0xFF1A1A1A), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + const Text('Window:', + style: TextStyle(color: Colors.grey, fontSize: 12)), + const SizedBox(width: 8), + GraphTimeSelector( + windowSeconds: window, + onChanged: (s) => + ref.read(_graphWindowProvider.notifier).state = s, + ), + const Spacer(), + IconButton( + icon: Icon(Icons.add_chart, color: colors.accent, size: 20), + tooltip: 'Add sensor', + onPressed: () => _addSensor(context, ref, sensors), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + // Graph panels + Expanded( + child: sensors.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.show_chart, + size: 48, color: colors.accent.withAlpha(80)), + const SizedBox(height: 12), + const Text('No sensors selected', + style: TextStyle(color: Colors.grey)), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () => _addSensor(context, ref, sensors), + icon: const Icon(Icons.add, size: 16), + label: const Text('Add sensor'), + ), + ], + ), + ) + : ListView.builder( + itemCount: sensors.length, + itemBuilder: (context, i) => SensorGraphPanel( + sensorId: sensors[i], + history: history, + windowSeconds: window, + accentColor: colors.accent, + onRemove: () { + final next = List.from(sensors)..removeAt(i); + ref.read(_graphSensorsProvider.notifier).state = next; + }, + ), + ), + ), + ], + ); + } + + void _addSensor( + BuildContext context, WidgetRef ref, List current) { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1A1A1A), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => ListView( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text('ADD SENSOR TO GRAPH', + style: TextStyle( + color: Colors.white70, + fontSize: 12, + letterSpacing: 2)), + ), + const Divider(height: 1, color: Colors.white10), + ...sensorDefs.map((def) { + final already = current.contains(def.id); + return ListTile( + title: Text(def.displayName, + style: TextStyle( + color: already ? Colors.grey : Colors.white)), + subtitle: Text(def.unit, + style: const TextStyle( + color: Colors.grey, fontSize: 11)), + trailing: already + ? const Icon(Icons.check, + color: Colors.grey, size: 16) + : null, + onTap: already + ? null + : () { + ref.read(_graphSensorsProvider.notifier).state = + [...current, def.id]; + Navigator.pop(context); + }, + ); + }), + ], + ), + ); + } +} diff --git a/lib/ui/screens/graph/widgets/graph_time_selector.dart b/lib/ui/screens/graph/widgets/graph_time_selector.dart new file mode 100644 index 0000000..b872de0 --- /dev/null +++ b/lib/ui/screens/graph/widgets/graph_time_selector.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class GraphTimeSelector extends StatelessWidget { + final int windowSeconds; + final void Function(int seconds) onChanged; + + static const _options = [30, 60, 120, 300]; + + const GraphTimeSelector({ + super.key, + required this.windowSeconds, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + return Row( + mainAxisSize: MainAxisSize.min, + children: _options.map((s) { + final selected = s == windowSeconds; + return GestureDetector( + onTap: () => onChanged(s), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + margin: const EdgeInsets.only(left: 4), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: selected ? accent.withAlpha(30) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected ? accent : Colors.white24, + ), + ), + child: Text( + s >= 60 ? '${s ~/ 60}m' : '${s}s', + style: TextStyle( + color: selected ? accent : Colors.white54, + fontSize: 12, + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/ui/screens/graph/widgets/sensor_graph_panel.dart b/lib/ui/screens/graph/widgets/sensor_graph_panel.dart new file mode 100644 index 0000000..3c0dcd8 --- /dev/null +++ b/lib/ui/screens/graph/widgets/sensor_graph_panel.dart @@ -0,0 +1,133 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import '../../../../core/models/sensor_defs.dart'; +import '../../../../core/providers/sensor_history_provider.dart'; + +class SensorGraphPanel extends StatelessWidget { + final String sensorId; + final SensorHistory history; + final int windowSeconds; + final Color accentColor; + final VoidCallback onRemove; + + const SensorGraphPanel({ + super.key, + required this.sensorId, + required this.history, + required this.windowSeconds, + required this.accentColor, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + final def = sensorDefs.firstWhere( + (d) => d.id == sensorId, + orElse: () => sensorDefs.first, + ); + final allSpots = history.get(sensorId); + + // Slice to window + final spots = _window(allSpots, windowSeconds.toDouble()); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.fromLTRB(8, 10, 12, 8), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Text( + def.displayName.toUpperCase(), + style: TextStyle( + color: accentColor, + fontSize: 11, + letterSpacing: 1.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + Text( + def.unit, + style: const TextStyle(color: Colors.grey, fontSize: 11), + ), + const Spacer(), + if (spots.isNotEmpty) + Text( + _fmt(spots.last.y), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: onRemove, + child: const Icon(Icons.close, + size: 16, color: Colors.white38), + ), + ], + ), + const SizedBox(height: 8), + // Chart + SizedBox( + height: 90, + child: spots.length < 2 + ? const Center( + child: Text('Waiting for data…', + style: TextStyle(color: Colors.white30, + fontSize: 12))) + : LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => FlLine( + color: Colors.white10, + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + titlesData: const FlTitlesData(show: false), + minY: def.min.toDouble(), + maxY: def.max.toDouble(), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: accentColor, + barWidth: 1.5, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: accentColor.withAlpha(25), + ), + ), + ], + ), + duration: Duration.zero, + ), + ), + ], + ), + ); + } + + List _window(List spots, double windowSec) { + if (spots.isEmpty) return spots; + final maxX = spots.last.x; + final minX = maxX - windowSec; + return spots.where((s) => s.x >= minX).toList(); + } + + String _fmt(double v) => + v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2); +} diff --git a/lib/ui/screens/sensor_list/sensor_list_screen.dart b/lib/ui/screens/sensor_list/sensor_list_screen.dart new file mode 100644 index 0000000..472ab27 --- /dev/null +++ b/lib/ui/screens/sensor_list/sensor_list_screen.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/models/sensor_defs.dart'; +import '../../../core/providers/sensor_provider.dart'; +import '../../../core/providers/theme_provider.dart'; +import '../../../ui/theme/app_colors.dart'; +import 'widgets/sensor_row.dart'; + +class SensorListScreen extends ConsumerWidget { + const SensorListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(latestSensorProvider); + final colors = AppColors.forTheme(ref.watch(themeProvider)); + + return ListView.separated( + itemCount: sensorDefs.length, + separatorBuilder: (_, __) => + const Divider(height: 1, color: Colors.white10), + itemBuilder: (context, i) { + final def = sensorDefs[i]; + return SensorRow( + def: def, + value: sensorValue(state, def.id), + accentColor: colors.accent, + ); + }, + ); + } +} diff --git a/lib/ui/screens/sensor_list/widgets/sensor_row.dart b/lib/ui/screens/sensor_list/widgets/sensor_row.dart new file mode 100644 index 0000000..0af8d90 --- /dev/null +++ b/lib/ui/screens/sensor_list/widgets/sensor_row.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import '../../../../core/models/sensor_def.dart'; +import '../../../../core/protocol/sensor_state.dart'; + +class SensorRow extends StatelessWidget { + final SensorDef def; + final double value; + final Color accentColor; + + const SensorRow({ + super.key, + required this.def, + required this.value, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final normalized = + ((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0); + final isHigh = normalized > 0.8; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: Row( + children: [ + // Label + SizedBox( + width: 52, + child: Text( + def.displayName, + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + letterSpacing: 0.5, + ), + ), + ), + const SizedBox(width: 8), + // Bar gauge + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Stack( + children: [ + Container(height: 14, color: Colors.white10), + FractionallySizedBox( + widthFactor: normalized, + child: Container( + height: 14, + decoration: BoxDecoration( + color: isHigh + ? Colors.red.withAlpha(200) + : accentColor.withAlpha(180), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 10), + // Value + unit + SizedBox( + width: 80, + child: RichText( + textAlign: TextAlign.right, + text: TextSpan( + children: [ + TextSpan( + text: _fmt(value), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + TextSpan( + text: ' ${def.unit}', + style: TextStyle( + color: accentColor, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + String _fmt(double v) { + if (def.id == 'rpm' || def.id == 'gear') return v.toStringAsFixed(0); + if (v.abs() >= 100) return v.toStringAsFixed(1); + return v.toStringAsFixed(2); + } +} + +double sensorValue(SensorState s, String id) { + switch (id) { + case 'rpm': return s.rpm; + case 'vss': return s.vss; + case 'map': return s.map; + case 'tps': return s.tps; + case 'inj': return s.inj; + case 'ign': return s.ign; + case 'ect': return s.ect; + case 'iat': return s.iat; + case 'bat': return s.bat; + case 'o2': return s.o2; + case 'gear': return s.gear.toDouble(); + case 'eth': return s.eth; + case 'pa': return s.pa; + case 'afr': return s.afr; + case 'strim': return s.strim; + case 'ltrim': return s.ltrim; + case 'ain0': return s.ain0; + case 'ain1': return s.ain1; + case 'ain2': return s.ain2; + case 'ain3': return s.ain3; + case 'ain4': return s.ain4; + case 'ain5': return s.ain5; + case 'ain6': return s.ain6; + case 'ain7': return s.ain7; + default: return 0; + } +} diff --git a/lib/ui/screens/settings/settings_screen.dart b/lib/ui/screens/settings/settings_screen.dart new file mode 100644 index 0000000..f5d5617 --- /dev/null +++ b/lib/ui/screens/settings/settings_screen.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/bluetooth/bt_poller.dart'; +import '../../../core/providers/bt_provider.dart'; +import '../../../core/providers/settings_provider.dart'; +import '../../../core/providers/theme_provider.dart'; +import '../../theme/app_colors.dart'; +import '../bt_picker/bt_picker_screen.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final themeVariant = ref.watch(themeProvider); + final bt = ref.watch(btProvider); + final colors = AppColors.forTheme(themeVariant); + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + // ── Connection ─────────────────────────────────────────── + _SectionHeader('CONNECTION'), + ListTile( + leading: Icon(Icons.bluetooth, color: colors.accent), + title: const Text('Bluetooth Device'), + subtitle: Text( + bt.isConnected + ? 'Connected: ${bt.deviceName}' + : 'Not connected', + style: TextStyle( + color: bt.isConnected ? Colors.green : Colors.grey), + ), + trailing: bt.isConnected + ? TextButton( + onPressed: () async { + await ref.read(btProvider.notifier).disconnect(); + }, + child: const Text('Disconnect', + style: TextStyle(color: Colors.red)), + ) + : const Icon(Icons.arrow_forward_ios, + size: 14, color: Colors.grey), + onTap: bt.isConnected + ? null + : () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const BtPickerScreen()), + ), + ), + const Divider(height: 1, color: Colors.white10), + + // ── ECU Protocol ───────────────────────────────────────── + _SectionHeader('ECU PROTOCOL'), + RadioListTile( + title: const Text('S300'), + subtitle: const Text('Honda S300 ECU', + style: TextStyle(color: Colors.grey, fontSize: 12)), + value: EcuType.s300, + groupValue: settings.ecuType, + activeColor: colors.accent, + onChanged: (v) { + if (v != null) + ref.read(settingsProvider.notifier).setEcuType(v); + }, + ), + RadioListTile( + title: const Text('KPro'), + subtitle: const Text('Hondata KPro ECU', + style: TextStyle(color: Colors.grey, fontSize: 12)), + value: EcuType.kpro, + groupValue: settings.ecuType, + activeColor: colors.accent, + onChanged: (v) { + if (v != null) + ref.read(settingsProvider.notifier).setEcuType(v); + }, + ), + const Divider(height: 1, color: Colors.white10), + + // ── Polling Interval ───────────────────────────────────── + _SectionHeader('POLLING INTERVAL'), + ...[50, 100, 200].map((ms) => RadioListTile( + title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'), + value: ms, + groupValue: settings.pollingInterval.inMilliseconds, + activeColor: colors.accent, + onChanged: (v) { + if (v != null) { + ref + .read(settingsProvider.notifier) + .setPollingInterval(Duration(milliseconds: v)); + } + }, + )), + const Divider(height: 1, color: Colors.white10), + + // ── Theme ──────────────────────────────────────────────── + _SectionHeader('THEME'), + ...AppThemeVariant.values.map((v) { + final label = switch (v) { + AppThemeVariant.redDark => 'Red — Dark', + AppThemeVariant.redLight => 'Red — Light', + AppThemeVariant.greenDark => 'Green — Dark', + AppThemeVariant.greenLight => 'Green — Light', + }; + return RadioListTile( + title: Text(label), + value: v, + groupValue: themeVariant, + activeColor: AppColors.forTheme(v).accent, + onChanged: (val) { + if (val != null) ref.read(themeProvider.notifier).set(val); + }, + ); + }), + const Divider(height: 1, color: Colors.white10), + + // ── App info ───────────────────────────────────────────── + _SectionHeader('ABOUT'), + const ListTile( + title: Text('HV BT Dashboard'), + subtitle: Text('v1.4.0 — Phase 4', + style: TextStyle(color: Colors.grey, fontSize: 12)), + leading: Icon(Icons.info_outline, color: Colors.grey), + ), + const SizedBox(height: 24), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + const _SectionHeader(this.title); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), + child: Text( + title, + style: const TextStyle( + color: Colors.grey, + fontSize: 11, + letterSpacing: 1.5, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/ui/theme/app_colors.dart b/lib/ui/theme/app_colors.dart new file mode 100644 index 0000000..05fcc50 --- /dev/null +++ b/lib/ui/theme/app_colors.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import '../../core/providers/theme_provider.dart'; + +class AppColors { + final Color accent; + final Color background; + final Color surface; + final Color surfaceVariant; + final Color onSurface; + final Color onSurfaceMuted; + + const AppColors._({ + required this.accent, + required this.background, + required this.surface, + required this.surfaceVariant, + required this.onSurface, + required this.onSurfaceMuted, + }); + + static const redDark = AppColors._( + accent: Color(0xFFFF4444), + background: Color(0xFF0A0A0A), + surface: Color(0xFF1A1A1A), + surfaceVariant: Color(0xFF242424), + onSurface: Colors.white, + onSurfaceMuted: Colors.grey, + ); + + static const redLight = AppColors._( + accent: Color(0xFFCC0000), + background: Color(0xFFF2F2F2), + surface: Color(0xFFFFFFFF), + surfaceVariant: Color(0xFFEAEAEA), + onSurface: Color(0xFF111111), + onSurfaceMuted: Color(0xFF777777), + ); + + static const greenDark = AppColors._( + accent: Color(0xFF00E676), + background: Color(0xFF0A0A0A), + surface: Color(0xFF1A1A1A), + surfaceVariant: Color(0xFF242424), + onSurface: Colors.white, + onSurfaceMuted: Colors.grey, + ); + + static const greenLight = AppColors._( + accent: Color(0xFF00875A), + background: Color(0xFFF2F2F2), + surface: Color(0xFFFFFFFF), + surfaceVariant: Color(0xFFEAEAEA), + onSurface: Color(0xFF111111), + onSurfaceMuted: Color(0xFF777777), + ); + + static AppColors forTheme(AppThemeVariant v) { + switch (v) { + case AppThemeVariant.redDark: return redDark; + case AppThemeVariant.redLight: return redLight; + case AppThemeVariant.greenDark: return greenDark; + case AppThemeVariant.greenLight: return greenLight; + } + } +} diff --git a/lib/ui/theme/app_theme.dart b/lib/ui/theme/app_theme.dart new file mode 100644 index 0000000..98f5e64 --- /dev/null +++ b/lib/ui/theme/app_theme.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import '../../core/providers/theme_provider.dart'; +import 'app_colors.dart'; + +class AppTheme { + static ThemeData build(AppThemeVariant variant) { + final colors = AppColors.forTheme(variant); + final isDark = variant.isDark; + + return ThemeData( + useMaterial3: true, + brightness: isDark ? Brightness.dark : Brightness.light, + scaffoldBackgroundColor: colors.background, + colorScheme: ColorScheme.fromSeed( + seedColor: colors.accent, + brightness: isDark ? Brightness.dark : Brightness.light, + ).copyWith( + primary: colors.accent, + surface: colors.surface, + ), + appBarTheme: AppBarTheme( + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: colors.surface, + selectedItemColor: colors.accent, + unselectedItemColor: colors.onSurfaceMuted, + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + cardTheme: CardThemeData( + color: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + dividerTheme: DividerThemeData( + color: colors.surfaceVariant, + thickness: 1, + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colors.accent, + foregroundColor: isDark ? Colors.black : Colors.white, + ), + textTheme: TextTheme( + bodyMedium: TextStyle(color: colors.onSurface), + bodySmall: TextStyle(color: colors.onSurfaceMuted), + ), + ); + } +} diff --git a/lib/ui/widgets/bt_status_chip.dart b/lib/ui/widgets/bt_status_chip.dart new file mode 100644 index 0000000..a8f91c1 --- /dev/null +++ b/lib/ui/widgets/bt_status_chip.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/bt_provider.dart'; + +class BtStatusChip extends ConsumerWidget { + const BtStatusChip({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bt = ref.watch(btProvider); + + final (color, icon, label) = switch (bt.status) { + BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'), + BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'), + BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'), + BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'), + }; + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/main_nav.dart b/lib/ui/widgets/main_nav.dart new file mode 100644 index 0000000..9d4ef07 --- /dev/null +++ b/lib/ui/widgets/main_nav.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../screens/dashboard/dashboard_screen.dart'; +import '../screens/sensor_list/sensor_list_screen.dart'; +import '../screens/graph/graph_screen.dart'; +import '../screens/dtc/dtc_screen.dart'; +import '../screens/datalog/datalog_list_screen.dart'; +import '../screens/debug/raw_frame_debug_screen.dart'; +import '../screens/settings/settings_screen.dart'; +import 'bt_status_chip.dart'; +import 'record_fab.dart'; + +final _navIndexProvider = StateProvider((ref) => 0); + +class MainNav extends ConsumerWidget { + const MainNav({super.key}); + + static const _screens = [ + DashboardScreen(), + SensorListScreen(), + GraphScreen(), + DtcScreen(), + ]; + + static const _items = [ + BottomNavigationBarItem( + icon: Icon(Icons.speed), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.list_alt), + label: 'Sensors', + ), + BottomNavigationBarItem( + icon: Icon(Icons.show_chart), + label: 'Graph', + ), + BottomNavigationBarItem( + icon: Icon(Icons.warning_amber_outlined), + label: 'DTC', + ), + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final index = ref.watch(_navIndexProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('HV BT Dashboard'), + actions: [ + const BtStatusChip(), + IconButton( + icon: const Icon(Icons.folder_open_outlined), + tooltip: 'Saved Sessions', + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const DatalogListScreen()), + ), + ), + IconButton( + icon: const Icon(Icons.bug_report_outlined), + tooltip: 'ECU Frame Debug', + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()), + ), + ), + IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: 'Settings', + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ), + ), + ], + ), + body: IndexedStack( + index: index, + children: _screens, + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: index, + items: _items, + onTap: (i) => ref.read(_navIndexProvider.notifier).state = i, + ), + floatingActionButton: const RecordFab(), + ); + } +} diff --git a/lib/ui/widgets/record_fab.dart b/lib/ui/widgets/record_fab.dart new file mode 100644 index 0000000..c362c74 --- /dev/null +++ b/lib/ui/widgets/record_fab.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/bt_provider.dart'; +import '../../core/providers/datalog_provider.dart'; + +class RecordFab extends ConsumerWidget { + const RecordFab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final recording = ref.watch(recordingProvider); + final bt = ref.watch(btProvider); + + // Only show when BT is connected + if (!bt.isConnected) return const SizedBox.shrink(); + + final isRecording = recording.isRecording; + + return FloatingActionButton( + heroTag: 'record_fab', + backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary, + onPressed: () async { + if (isRecording) { + await ref.read(recordingProvider.notifier).stopRecording(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Session saved — ${recording.frameCount} frames', + ), + duration: const Duration(seconds: 2), + ), + ); + } + } else { + await ref.read(recordingProvider.notifier).startRecording(); + } + }, + tooltip: isRecording ? 'Stop recording' : 'Start recording', + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isRecording + ? const Icon(Icons.stop, key: ValueKey('stop')) + : const Icon(Icons.fiber_manual_record, key: ValueKey('rec')), + ), + ); + } +}