diff --git a/lib/core/datalog/datalog_player.dart b/lib/core/datalog/datalog_player.dart index 5207236..41bb160 100644 --- a/lib/core/datalog/datalog_player.dart +++ b/lib/core/datalog/datalog_player.dart @@ -65,9 +65,12 @@ class DatalogPlayer { _status = PlaybackStatus.idle; } - /// Jump to a specific frame index. + /// Jump to a specific frame index. The frame there is emitted at once so + /// the displays follow the slider even while paused. void seek(int index) { + if (_frames.isEmpty) return; _position = index.clamp(0, _frames.length - 1); + _controller.add(_frames[_position].data); onProgress?.call(_position, _frames.length); } diff --git a/lib/core/models/flag_def.dart b/lib/core/models/flag_def.dart index 40d1a83..d6b45e4 100644 --- a/lib/core/models/flag_def.dart +++ b/lib/core/models/flag_def.dart @@ -1,7 +1,14 @@ +import '../protocol/sensor_state.dart'; + /// Definition of a boolean flag sensor shown as indicator pills. class FlagDef { final String id; final String displayName; + final bool Function(SensorState s) read; - const FlagDef({required this.id, required this.displayName}); + const FlagDef({ + required this.id, + required this.displayName, + required this.read, + }); } diff --git a/lib/core/models/sensor_def.dart b/lib/core/models/sensor_def.dart index 00e03f1..686adfd 100644 --- a/lib/core/models/sensor_def.dart +++ b/lib/core/models/sensor_def.dart @@ -5,6 +5,8 @@ class SensorDef { final String unit; final double min; final double max; + /// Decimal places shown for this sensor in its base unit. + final int decimals; const SensorDef({ required this.id, @@ -12,5 +14,6 @@ class SensorDef { required this.unit, required this.min, required this.max, + this.decimals = 1, }); } diff --git a/lib/core/models/sensor_defs.dart b/lib/core/models/sensor_defs.dart index 081a366..5908b72 100644 --- a/lib/core/models/sensor_defs.dart +++ b/lib/core/models/sensor_defs.dart @@ -1,22 +1,27 @@ +import '../protocol/sensor_state.dart'; import 'sensor_def.dart'; import 'flag_def.dart'; -/// Master list of all analog sensor definitions. +/// Master list of all analog sensor definitions. Units, ranges and decimals +/// are the base (metric) ones; see units.dart for the displayed unit. const List sensorDefs = [ - SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000), - SensorDef(id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280), SensorDef( - id: 'map', - displayName: 'Manifold pressure', - unit: 'kPa', + id: 'rpm', displayName: 'RPM', unit: 'rpm', min: 0, max: 9000, decimals: 0), + SensorDef( + id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280, decimals: 0), + SensorDef( + id: 'map', displayName: 'Manifold pressure', unit: 'kPa', min: 0, max: 300), + SensorDef( + id: 'tps', displayName: 'Throttle position', unit: '%', min: 0, max: 100), + SensorDef( + id: 'inj', + displayName: 'Injector duration', + unit: 'ms', min: 0, - max: 300), + max: 20, + decimals: 2), SensorDef( - id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100), - SensorDef( - id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20), - SensorDef( - id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60), + id: 'ign', displayName: 'Timing advance', unit: '°', min: -20, max: 60), SensorDef( id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150), SensorDef( @@ -28,41 +33,99 @@ const List sensorDefs = [ SensorDef( id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20), // O2/AFR/AIN scales not yet bench-verified: shown as raw values. - SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'raw', min: 0, max: 255), - SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6), - SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100), SensorDef( - id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120), - SensorDef(id: 'afr', displayName: 'AFR', unit: 'raw', min: 0, max: 255), + id: 'o2', displayName: 'O2 sensor', unit: 'raw', min: 0, max: 255, decimals: 0), + SensorDef( + id: 'gear', displayName: 'Gear', unit: '', min: 0, max: 6, decimals: 0), + SensorDef( + id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100, decimals: 0), + SensorDef( + id: 'pa', displayName: 'Barometric pressure', unit: 'kPa', min: 0, max: 120), + SensorDef( + id: 'afr', displayName: 'AFR', unit: 'raw', min: 0, max: 255, decimals: 0), SensorDef( id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30), SensorDef( id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30), SensorDef( - id: 'ain0', displayName: 'Analog input 0', unit: 'raw', min: 0, max: 1023), + id: 'ain0', displayName: 'Analog input 0', unit: 'raw', min: 0, max: 1023, decimals: 0), SensorDef( - id: 'ain1', displayName: 'Analog input 1', unit: 'raw', min: 0, max: 1023), + id: 'ain1', displayName: 'Analog input 1', unit: 'raw', min: 0, max: 1023, decimals: 0), SensorDef( - id: 'ain2', displayName: 'Analog input 2', unit: 'raw', min: 0, max: 1023), + id: 'ain2', displayName: 'Analog input 2', unit: 'raw', min: 0, max: 1023, decimals: 0), SensorDef( - id: 'ain3', displayName: 'Analog input 3', unit: 'raw', min: 0, max: 1023), + id: 'ain3', displayName: 'Analog input 3', unit: 'raw', min: 0, max: 1023, decimals: 0), SensorDef( - id: 'ain4', displayName: 'Analog input 4', unit: 'raw', min: 0, max: 1023), + id: 'ain4', displayName: 'Analog input 4', unit: 'raw', min: 0, max: 1023, decimals: 0), SensorDef( - id: 'ain5', displayName: 'Analog input 5', unit: 'raw', min: 0, max: 1023), + id: 'ain5', displayName: 'Analog input 5', unit: 'raw', min: 0, max: 1023, decimals: 0), SensorDef( - id: 'ain6', displayName: 'Analog input 6', unit: 'raw', min: 0, max: 1023), + id: 'ain6', displayName: 'Analog input 6', unit: 'raw', min: 0, max: 1023, decimals: 0), SensorDef( - id: 'ain7', displayName: 'Analog input 7', unit: 'raw', min: 0, max: 1023), + id: 'ain7', displayName: 'Analog input 7', unit: 'raw', min: 0, max: 1023, decimals: 0), ]; -/// Master list of boolean flag sensors. -const List flagDefs = [ - FlagDef(id: 'mil', displayName: 'MIL'), - FlagDef(id: 'fuelpump', displayName: 'Fuel Pump'), - FlagDef(id: 'fanout', displayName: 'FAN Out'), - FlagDef(id: 'vtec', displayName: 'VTEC'), - FlagDef(id: 'knock', displayName: 'Knock'), - FlagDef(id: 'revlimit', displayName: 'Rev Limit'), - FlagDef(id: 'launch', displayName: 'Launch'), +SensorDef sensorDef(String id) => + sensorDefs.firstWhere((d) => d.id == id, orElse: () => sensorDefs.first); + +/// Base-unit value of sensor [id] in [s]. +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; + } +} + +/// Master list of boolean flags (selectable on the dashboard). The first +/// eight follow the requirement document's examples; the rest are the other +/// status bits the ECU frame carries. +final List flagDefs = [ + FlagDef(id: 'mil', displayName: 'MIL', read: (s) => s.mil), + FlagDef(id: 'fanout', displayName: 'FAN Out', read: (s) => s.fanOut), + FlagDef(id: 'vtec', displayName: 'VTEC Out', read: (s) => s.vtec), + FlagDef(id: 'knock', displayName: 'Knock', read: (s) => s.knock), + FlagDef(id: 'o2error', displayName: 'WBO Error', read: (s) => s.o2Error), + FlagDef(id: 'iaterror', displayName: 'IAT Error', read: (s) => s.iatError), + FlagDef(id: 'ecterror', displayName: 'CLT Error', read: (s) => s.ectError), + FlagDef(id: 'fuelpump', displayName: 'Fuel Pump', read: (s) => s.fuelPump), + FlagDef(id: 'revlimit', displayName: 'Rev Limit', read: (s) => s.revLimit), + FlagDef(id: 'igncut', displayName: 'Ign Cut', read: (s) => s.ignCut), + FlagDef(id: 'boostcut', displayName: 'Boost Cut', read: (s) => s.boostCut), + FlagDef(id: 'launch', displayName: 'Launch Cut', read: (s) => s.launch), + FlagDef( + id: 'launchretard', + displayName: 'Launch Retard', + read: (s) => s.launchRetard), + FlagDef(id: 'shiftcut', displayName: 'Shift Cut', read: (s) => s.shiftCut), + FlagDef( + id: 'closedloop', displayName: 'Closed Loop', read: (s) => s.closedLoop), + FlagDef(id: 'vtp', displayName: 'VTEC Press', read: (s) => s.vtp), + FlagDef(id: 'acswitch', displayName: 'AC Switch', read: (s) => s.acSwitch), + FlagDef(id: 'brake', displayName: 'Brake', read: (s) => s.brakeSwitch), ]; + +FlagDef flagDef(String id) => + flagDefs.firstWhere((f) => f.id == id, orElse: () => flagDefs.first); diff --git a/lib/core/models/units.dart b/lib/core/models/units.dart new file mode 100644 index 0000000..a29c794 --- /dev/null +++ b/lib/core/models/units.dart @@ -0,0 +1,101 @@ +import 'sensor_def.dart'; +import 'sensor_defs.dart'; + +enum SpeedUnit { kmh, mph } + +enum TempUnit { celsius, fahrenheit } + +enum PressureUnit { kpa, bar, psi } + +/// User's display units. Values are always stored and logged in the base +/// (metric) units of [sensorDefs]; conversion happens only for display. +class UnitPrefs { + final SpeedUnit speed; + final TempUnit temp; + final PressureUnit pressure; + + const UnitPrefs({ + this.speed = SpeedUnit.kmh, + this.temp = TempUnit.celsius, + this.pressure = PressureUnit.kpa, + }); + + UnitPrefs copyWith({ + SpeedUnit? speed, + TempUnit? temp, + PressureUnit? pressure, + }) => + UnitPrefs( + speed: speed ?? this.speed, + temp: temp ?? this.temp, + pressure: pressure ?? this.pressure, + ); + + @override + bool operator ==(Object other) => + other is UnitPrefs && + other.speed == speed && + other.temp == temp && + other.pressure == pressure; + + @override + int get hashCode => Object.hash(speed, temp, pressure); +} + +/// How one sensor is displayed under the current [UnitPrefs]: unit label, +/// converted range, and value conversion/formatting. +class SensorView { + final SensorDef def; + final String unit; + final double min; + final double max; + final int decimals; + final double Function(double base) _convert; + + const SensorView._( + this.def, this.unit, this.min, this.max, this.decimals, this._convert); + + String get name => def.displayName; + + /// Converts a base-unit value to the display unit. + double convert(double base) => _convert(base); + + /// Converted and formatted, without unit. + String format(double base) => convert(base).toStringAsFixed(decimals); + + /// 0..1 position of [base] within the sensor's range. + double fraction(double base) => + ((convert(base) - min) / (max - min)).clamp(0.0, 1.0); +} + +double _id(double v) => v; + +SensorView sensorView(String id, UnitPrefs u) { + final d = sensorDef(id); + SensorView view(String unit, double Function(double) f, {int? decimals}) => + SensorView._(d, unit, f(d.min), f(d.max), decimals ?? d.decimals, f); + + switch (id) { + case 'vss': + return u.speed == SpeedUnit.mph + ? view('mph', (v) => v * 0.621371) + : view(d.unit, _id); + case 'ect': + case 'iat': + return u.temp == TempUnit.fahrenheit + ? view('°F', (v) => v * 9 / 5 + 32) + : view(d.unit, _id); + case 'map': + case 'pa': + switch (u.pressure) { + case PressureUnit.bar: + return view('bar', (v) => v / 100, decimals: 2); + case PressureUnit.psi: + return view('psi', (v) => v * 0.145038); + case PressureUnit.kpa: + return view(d.unit, _id); + } + default: + return view(d.unit, _id); + } +} diff --git a/lib/core/protocol/dtc_map.dart b/lib/core/protocol/dtc_map.dart index e4175d6..3715604 100644 --- a/lib/core/protocol/dtc_map.dart +++ b/lib/core/protocol/dtc_map.dart @@ -238,6 +238,30 @@ const Map kproDtcMap = { 'ERR17_bit7': 'ERR17.7 — Undocumented code', }; +/// True if any of [bits] — (ERR byte index, bit number) pairs — is set. +/// Used to derive sensor-error flags (IAT/CLT/O2) from the stored codes. +bool anyDtcBit(List errBytes, List<(int, int)> bits) { + for (final (byte, bit) in bits) { + if (byte < errBytes.length && (errBytes[byte] >> bit) & 1 == 1) { + return true; + } + } + return false; +} + +// Sensor-error flag sources, per the DTC maps above. +const s300IatErrorBits = [(1, 1)]; // P0113 +const s300EctErrorBits = [(0, 5)]; // P0106 +const s300O2ErrorBits = [(0, 0), (0, 1), (3, 2), (3, 3)]; // O2A/B, heaters +const kproIatErrorBits = [(2, 6), (2, 7)]; // P0112/P0113 +const kproEctErrorBits = [(1, 2), (1, 3), (12, 4)]; // P0117/P0118/P0116 +const kproO2ErrorBits = [ + (0, 2), (0, 3), // O2 voltage low/high + (6, 0), (6, 1), (6, 2), // A/F (LAF) sensor circuit + (7, 4), (7, 5), (7, 6), (7, 7), // HO2S / LAF heater, no activity + (9, 3), (9, 4), // A/F slow response, range +]; + /// Returns a list of active DTC descriptions from the given raw error bytes /// using the provided map. List decodeDtcs(List errBytes, Map dtcMap) { diff --git a/lib/core/protocol/kpro_parser.dart b/lib/core/protocol/kpro_parser.dart index 483b23f..637029a 100644 --- a/lib/core/protocol/kpro_parser.dart +++ b/lib/core/protocol/kpro_parser.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'sensor_state.dart'; import 'temp_table.dart'; import 'neg8.dart'; +import 'dtc_map.dart'; /// Parses a 128-byte KPro ECU response frame into a [SensorState]. /// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails. @@ -140,6 +141,9 @@ SensorState parseKPro(Uint8List frame) { knock: knock, revLimit: revLimit, launch: launch, + iatError: anyDtcBit(errBytes, kproIatErrorBits), + ectError: anyDtcBit(errBytes, kproEctErrorBits), + o2Error: anyDtcBit(errBytes, kproO2ErrorBits), errBytes: errBytes, timestamp: DateTime.now(), ); diff --git a/lib/core/protocol/s300_parser.dart b/lib/core/protocol/s300_parser.dart index a2f97e9..afc7bdd 100644 --- a/lib/core/protocol/s300_parser.dart +++ b/lib/core/protocol/s300_parser.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'sensor_state.dart'; import 'temp_table.dart'; import 'neg8.dart'; +import 'dtc_map.dart'; // ───────────────────────────────────────────────────────────────────────────── // S300 datalog frame (128 bytes, response to request 1B 00 E5). @@ -146,6 +147,14 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) { final bool knock = frame[13] > 0; // KRtrd // SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3) final bool launch = (sw3 & 0x80) != 0; + final bool launchRetard = (sw3 & 0x40) != 0; + final bool ignCut = (sw3 & 0x20) != 0; + final bool shiftCut = (sw3 & 0x10) != 0; + final bool boostCut = (sw3 & 0x08) != 0; + final bool closedLoop = (sw5 & 0x04) != 0; // SW5 CL + final bool brakeSwitch = (sw1 & 0x04) != 0; // SW1 BKSW + final bool acSwitch = (sw1 & 0x02) != 0; // SW1 ACSW + final bool vtp = (sw1 & 0x01) != 0; // SW1 VTP (VTEC pressure) return SensorState( rpm: rpm, @@ -179,6 +188,17 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) { knock: knock, revLimit: revLimit, launch: launch, + launchRetard: launchRetard, + ignCut: ignCut, + shiftCut: shiftCut, + boostCut: boostCut, + closedLoop: closedLoop, + brakeSwitch: brakeSwitch, + acSwitch: acSwitch, + vtp: vtp, + iatError: anyDtcBit(errBytes, s300IatErrorBits), + ectError: anyDtcBit(errBytes, s300EctErrorBits), + o2Error: anyDtcBit(errBytes, s300O2ErrorBits), errBytes: errBytes, timestamp: DateTime.now(), ); diff --git a/lib/core/protocol/sensor_state.dart b/lib/core/protocol/sensor_state.dart index 6ae293c..1a4e704 100644 --- a/lib/core/protocol/sensor_state.dart +++ b/lib/core/protocol/sensor_state.dart @@ -33,6 +33,19 @@ class SensorState { final bool knock; final bool revLimit; final bool launch; + // Extra flags (default false where an ECU does not report them). + // iat/ect/o2Error are derived from the stored DTC bits. + final bool ignCut; + final bool boostCut; + final bool shiftCut; + final bool launchRetard; + final bool closedLoop; + final bool acSwitch; + final bool brakeSwitch; + final bool vtp; + final bool iatError; + final bool ectError; + final bool o2Error; // Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300) final List errBytes; // Frame timestamp @@ -70,6 +83,17 @@ class SensorState { required this.knock, required this.revLimit, required this.launch, + this.ignCut = false, + this.boostCut = false, + this.shiftCut = false, + this.launchRetard = false, + this.closedLoop = false, + this.acSwitch = false, + this.brakeSwitch = false, + this.vtp = false, + this.iatError = false, + this.ectError = false, + this.o2Error = false, required this.errBytes, required this.timestamp, }); @@ -177,6 +201,17 @@ class SensorState { knock: knock ?? this.knock, revLimit: revLimit ?? this.revLimit, launch: launch ?? this.launch, + ignCut: ignCut, + boostCut: boostCut, + shiftCut: shiftCut, + launchRetard: launchRetard, + closedLoop: closedLoop, + acSwitch: acSwitch, + brakeSwitch: brakeSwitch, + vtp: vtp, + iatError: iatError, + ectError: ectError, + o2Error: o2Error, errBytes: errBytes ?? this.errBytes, timestamp: timestamp ?? this.timestamp, ); diff --git a/lib/core/providers/datalog_provider.dart b/lib/core/providers/datalog_provider.dart index da9e592..4cc2daa 100644 --- a/lib/core/providers/datalog_provider.dart +++ b/lib/core/providers/datalog_provider.dart @@ -4,7 +4,9 @@ import '../datalog/datalog_db.dart'; import '../datalog/datalog_player.dart'; import '../datalog/datalog_recorder.dart'; import '../models/datalog_session.dart'; +import '../bluetooth/bt_poller.dart'; import 'bt_provider.dart'; +import 'sensor_history_provider.dart'; import 'settings_provider.dart'; // ─── Singletons ─────────────────────────────────────────────────────────────── @@ -114,6 +116,8 @@ final sessionListProvider = FutureProvider>((ref) { class PlaybackState { final bool isActive; final int? sessionId; + /// ECU type the session was recorded with; frames are parsed with it. + final EcuType ecuType; final int position; final int total; final double speed; @@ -122,6 +126,7 @@ class PlaybackState { const PlaybackState({ this.isActive = false, this.sessionId, + this.ecuType = EcuType.s300, this.position = 0, this.total = 0, this.speed = 1.0, @@ -141,6 +146,7 @@ class PlaybackState { PlaybackState( isActive: isActive ?? this.isActive, sessionId: sessionId ?? this.sessionId, + ecuType: ecuType, position: position ?? this.position, total: total ?? this.total, speed: speed ?? this.speed, @@ -153,9 +159,12 @@ class PlaybackNotifier extends StateNotifier { PlaybackNotifier(this._ref) : super(const PlaybackState()); - Future loadSession(int sessionId) async { + /// Loads a recorded session; while active, the dashboard, sensor list, + /// graph and DTC screen show the recording instead of live data. + Future loadSession(int sessionId, String ecuType) async { final player = _ref.read(datalogPlayerProvider); await player.load(sessionId); + _ref.read(sensorHistoryProvider.notifier).clear(); player.onProgress = (pos, total) { state = state.copyWith( @@ -168,6 +177,7 @@ class PlaybackNotifier extends StateNotifier { state = PlaybackState( isActive: true, sessionId: sessionId, + ecuType: ecuType == EcuType.kpro.name ? EcuType.kpro : EcuType.s300, total: player.totalFrames, speed: player.speed, status: PlaybackStatus.idle, @@ -187,6 +197,7 @@ class PlaybackNotifier extends StateNotifier { Future stop() async { await _ref.read(datalogPlayerProvider).stop(); state = const PlaybackState(); + _ref.read(sensorHistoryProvider.notifier).clear(); } void seek(int index) { @@ -204,3 +215,11 @@ final playbackProvider = StateNotifierProvider( (ref) => PlaybackNotifier(ref), ); + +/// ECU type the displays should decode with: the recorded session's while a +/// datalog is playing back, otherwise the live setting. +final effectiveEcuTypeProvider = Provider((ref) { + final playback = ref.watch(playbackProvider); + if (playback.isActive) return playback.ecuType; + return ref.watch(settingsProvider.select((s) => s.ecuType)); +}); diff --git a/lib/core/providers/sensor_provider.dart b/lib/core/providers/sensor_provider.dart index d2deb79..9ab6ae0 100644 --- a/lib/core/providers/sensor_provider.dart +++ b/lib/core/providers/sensor_provider.dart @@ -7,7 +7,7 @@ import '../protocol/kpro_parser.dart'; import '../protocol/s300_parser.dart'; import '../protocol/sensor_state.dart'; import 'bt_provider.dart'; -import 'settings_provider.dart'; +import 'datalog_provider.dart'; // NEG8 is only an 8-bit checksum, so roughly 1 in 256 corrupted frames can // still pass validation — rare, but enough to occasionally show an @@ -52,16 +52,19 @@ class DecodeStats { final decodeStatsProvider = StateProvider((ref) => const DecodeStats()); -/// Emits a parsed [SensorState] for every live data frame received from the ECU. -/// Automatically picks S300 or KPro parser based on [settingsProvider]. +/// Emits a parsed [SensorState] for every live data frame received from the +/// ECU — or, while a datalog plays back, for every recorded frame. Picks the +/// S300 or KPro parser via [effectiveEcuTypeProvider]. final sensorStateProvider = StreamProvider((ref) async* { - final ecuType = ref.watch(settingsProvider.select((s) => s.ecuType)); - final btNotifier = ref.watch(btProvider.notifier); + final ecuType = ref.watch(effectiveEcuTypeProvider); + final playing = ref.watch(playbackProvider.select((p) => p.isActive)); + final source = playing + ? ref.watch(datalogPlayerProvider).frameStream + : ref.watch(btProvider.notifier).frameStream; // `await for` treats an error event as fatal; absorb errors upstream so the // loop keeps waiting for the next frame. - final safeStream = - btNotifier.frameStream.handleError((Object _, StackTrace __) {}); + final safeStream = source.handleError((Object _, StackTrace __) {}); // Adapter default frames are already filtered out in BtNotifier, so every // frame here is a real ECU reading. No "hold last value" logic any more: diff --git a/lib/core/providers/settings_provider.dart b/lib/core/providers/settings_provider.dart index 8baa5a5..d865c99 100644 --- a/lib/core/providers/settings_provider.dart +++ b/lib/core/providers/settings_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../bluetooth/bt_poller.dart'; +import '../models/units.dart'; /// Polling choices offered in Settings. Never faster than 10 Hz: the adapter /// MCU must have time to read the ECU between requests (device maker's @@ -69,3 +70,65 @@ class SettingsNotifier extends StateNotifier { final settingsProvider = StateNotifierProvider( (ref) => SettingsNotifier(), ); + +// ─── Display units ─────────────────────────────────────────────────────────── + +const _unitKeys = (speed: 'unitSpeed', temp: 'unitTemp', pressure: 'unitPressure'); + +T? _byName(List values, String? name) { + for (final v in values) { + if (v.name == name) return v; + } + return null; +} + +/// Display units (speed / temperature / pressure), persisted. +class UnitPrefsNotifier extends StateNotifier { + UnitPrefsNotifier() : super(const UnitPrefs()) { + _load(); + } + + Future _load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final next = UnitPrefs( + speed: _byName(SpeedUnit.values, prefs.getString(_unitKeys.speed)) ?? + state.speed, + temp: _byName(TempUnit.values, prefs.getString(_unitKeys.temp)) ?? + state.temp, + pressure: _byName( + PressureUnit.values, prefs.getString(_unitKeys.pressure)) ?? + state.pressure, + ); + if (mounted) state = next; + } catch (_) { + // Keep defaults if preferences can't be read. + } + } + + void _save(String key, Enum v) { + SharedPreferences.getInstance() + .then((prefs) => prefs.setString(key, v.name)) + .catchError((Object _) => false); + } + + void setSpeed(SpeedUnit v) { + state = state.copyWith(speed: v); + _save(_unitKeys.speed, v); + } + + void setTemp(TempUnit v) { + state = state.copyWith(temp: v); + _save(_unitKeys.temp, v); + } + + void setPressure(PressureUnit v) { + state = state.copyWith(pressure: v); + _save(_unitKeys.pressure, v); + } +} + +final unitPrefsProvider = + StateNotifierProvider( + (ref) => UnitPrefsNotifier(), +); diff --git a/lib/core/providers/theme_provider.dart b/lib/core/providers/theme_provider.dart index c4ecb46..63b568d 100644 --- a/lib/core/providers/theme_provider.dart +++ b/lib/core/providers/theme_provider.dart @@ -1,12 +1,35 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; enum AppThemeVariant { redDark, redLight, greenDark, greenLight } -class ThemeNotifier extends StateNotifier { - ThemeNotifier() : super(AppThemeVariant.redDark); +const _themeKey = 'themeVariant'; - void set(AppThemeVariant v) => state = v; +/// Colour scheme (red/green) × brightness (dark/light), persisted. +class ThemeNotifier extends StateNotifier { + ThemeNotifier() : super(AppThemeVariant.redDark) { + _load(); + } + + Future _load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_themeKey); + for (final v in AppThemeVariant.values) { + if (v.name == saved && mounted) state = v; + } + } catch (_) { + // Keep the default if preferences can't be read. + } + } + + void set(AppThemeVariant v) { + state = v; + SharedPreferences.getInstance() + .then((prefs) => prefs.setString(_themeKey, v.name)) + .catchError((Object _) => false); + } } final themeProvider = diff --git a/lib/main.dart b/lib/main.dart index 4d03f16..9039dfc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -56,6 +56,13 @@ class HvbtApp extends ConsumerWidget { title: 'HV BT Dashboard', debugShowCheckedModeBanner: false, theme: AppTheme.build(themeVariant), + // Keep every screen clear of the system navigation bar, which sits on + // a side in landscape. + builder: (context, child) => SafeArea( + top: false, + bottom: false, + child: child ?? const SizedBox.shrink(), + ), home: const BtPickerScreen(), ); } diff --git a/lib/ui/screens/dashboard/dashboard_horizontal.dart b/lib/ui/screens/dashboard/dashboard_horizontal.dart index 748bf67..0feb5af 100644 --- a/lib/ui/screens/dashboard/dashboard_horizontal.dart +++ b/lib/ui/screens/dashboard/dashboard_horizontal.dart @@ -1,112 +1,81 @@ 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 '../../../core/providers/settings_provider.dart'; +import '../../theme/app_colors.dart'; +import 'dashboard_slots_provider.dart'; +import 'widgets/dashboard_common.dart'; import 'widgets/flag_pill.dart'; import 'widgets/sensor_tile.dart'; -import 'widgets/sensor_picker_sheet.dart'; -import 'dashboard_slots_provider.dart'; +/// Landscape layout (requirements): main + sub sensor in the centre, +/// 5 values left, 5 values right, 4 flags along the bottom. class DashboardHorizontal extends ConsumerWidget { const DashboardHorizontal({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final c = AppColors.of(context); final state = ref.watch(latestSensorProvider); - final themeVariant = ref.watch(themeProvider); - final colors = AppColors.forTheme(themeVariant); - final slots = ref.watch(dashboardSlotsProvider); + final config = ref.watch(dashboardConfigProvider); + final units = ref.watch(unitPrefsProvider); - 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 + Widget column(int from) => SizedBox( + width: 190, + child: Column( + children: [ + for (int i = from; i < from + 5; i++) Expanded( - child: Center( - child: AnalogGauge( - value: state.rpm, - minValue: 0, - maxValue: 9000, - label: 'RPM', - unit: 'rpm', - accentColor: colors.accent, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SensorTile( + sensorId: config.values[i], + state: state, + units: units, + onLongPress: () => editDashboardValue(context, ref, i), ), ), ), - 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); - }, + return Container( + color: c.background, + // SafeArea: in landscape the system navigation bar sits on a side. + child: SafeArea( + top: false, + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), + child: Column( + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + column(0), + const SizedBox(width: 8), + Expanded( + child: Center(child: MainGauge(state: state)), + ), + const SizedBox(width: 8), + column(5), + ], + ), + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 198), + child: FlagRow( + state: state, + flagIds: config.flags, + onLongPress: (i) => editDashboardFlag(context, ref, i), + ), + ), + ], + ), + ), ), ); } diff --git a/lib/ui/screens/dashboard/dashboard_slots_provider.dart b/lib/ui/screens/dashboard/dashboard_slots_provider.dart index d4e1a8d..6367776 100644 --- a/lib/ui/screens/dashboard/dashboard_slots_provider.dart +++ b/lib/ui/screens/dashboard/dashboard_slots_provider.dart @@ -1,19 +1,123 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -// Default sensor IDs for the 6 dashboard tile slots -const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat']; +import '../../../core/models/sensor_defs.dart'; -class DashboardSlotsNotifier extends StateNotifier> { - DashboardSlotsNotifier() : super(List.from(_defaultSlots)); +/// What the dashboard shows, per the requirement document: a selectable +/// main sensor (gauge) + sub sensor, 10 selectable values (landscape: 5 left +/// + 5 right; portrait: 2×5) and 4 selectable flags. Persisted. +class DashboardConfig { + static const valueCount = 10; + static const flagCount = 4; - void setSlot(int index, String sensorId) { - final next = List.from(state); - next[index] = sensorId; - state = next; + final String main; + final String sub; + final List values; + final List flags; + + const DashboardConfig({ + required this.main, + required this.sub, + required this.values, + required this.flags, + }); + + static const defaults = DashboardConfig( + main: 'rpm', + sub: 'vss', + values: [ + 'vss', 'ect', 'iat', 'map', 'o2', // left + 'tps', 'bat', 'ign', 'inj', 'eth', // right + ], + flags: ['mil', 'fanout', 'vtec', 'knock'], + ); + + DashboardConfig copyWith({ + String? main, + String? sub, + List? values, + List? flags, + }) => + DashboardConfig( + main: main ?? this.main, + sub: sub ?? this.sub, + values: values ?? this.values, + flags: flags ?? this.flags, + ); +} + +const _kMain = 'dash.main'; +const _kSub = 'dash.sub'; +const _kValues = 'dash.values'; +const _kFlags = 'dash.flags'; + +class DashboardConfigNotifier extends StateNotifier { + DashboardConfigNotifier() : super(DashboardConfig.defaults) { + _load(); + } + + static bool _isSensor(String id) => sensorDefs.any((d) => d.id == id); + static bool _isFlag(String id) => flagDefs.any((f) => f.id == id); + + Future _load() async { + try { + final p = await SharedPreferences.getInstance(); + const d = DashboardConfig.defaults; + List list(String key, int n, bool Function(String) valid, + List fallback) { + final saved = p.getStringList(key); + if (saved == null || saved.length != n || !saved.every(valid)) { + return fallback; + } + return saved; + } + + final main = p.getString(_kMain); + final sub = p.getString(_kSub); + final next = DashboardConfig( + main: main != null && _isSensor(main) ? main : d.main, + sub: sub != null && _isSensor(sub) ? sub : d.sub, + values: list(_kValues, DashboardConfig.valueCount, _isSensor, d.values), + flags: list(_kFlags, DashboardConfig.flagCount, _isFlag, d.flags), + ); + if (mounted) state = next; + } catch (_) { + // Keep defaults if preferences can't be read. + } + } + + void _persist() { + final s = state; + SharedPreferences.getInstance().then((p) { + p.setString(_kMain, s.main); + p.setString(_kSub, s.sub); + p.setStringList(_kValues, s.values); + p.setStringList(_kFlags, s.flags); + }).catchError((Object _) {}); + } + + void setMain(String id) { + state = state.copyWith(main: id); + _persist(); + } + + void setSub(String id) { + state = state.copyWith(sub: id); + _persist(); + } + + void setValue(int index, String id) { + state = state.copyWith(values: [...state.values]..[index] = id); + _persist(); + } + + void setFlag(int index, String id) { + state = state.copyWith(flags: [...state.flags]..[index] = id); + _persist(); } } -final dashboardSlotsProvider = - StateNotifierProvider>( - (ref) => DashboardSlotsNotifier(), +final dashboardConfigProvider = + StateNotifierProvider( + (ref) => DashboardConfigNotifier(), ); diff --git a/lib/ui/screens/dashboard/dashboard_vertical.dart b/lib/ui/screens/dashboard/dashboard_vertical.dart index 43b9a1c..f0392f9 100644 --- a/lib/ui/screens/dashboard/dashboard_vertical.dart +++ b/lib/ui/screens/dashboard/dashboard_vertical.dart @@ -1,81 +1,94 @@ +import 'dart:math' as math; + 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 '../../../core/providers/settings_provider.dart'; +import '../../theme/app_colors.dart'; +import 'dashboard_slots_provider.dart'; +import 'widgets/dashboard_common.dart'; import 'widgets/flag_pill.dart'; import 'widgets/sensor_tile.dart'; -import 'widgets/sensor_picker_sheet.dart'; -import 'dashboard_slots_provider.dart'; +/// Portrait layout (requirements): main + sub sensor on top, 4 flags in the +/// middle, 2×5 values below — all on one screen, the gauge taking whatever +/// height the flags and values leave. class DashboardVertical extends ConsumerWidget { const DashboardVertical({super.key}); + static const _tileHeight = 64.0; + static const _gap = 6.0; + static const _flagsHeight = 30.0 + 2 * _gap; + @override Widget build(BuildContext context, WidgetRef ref) { + final c = AppColors.of(context); final state = ref.watch(latestSensorProvider); - final themeVariant = ref.watch(themeProvider); - final colors = AppColors.forTheme(themeVariant); - final slots = ref.watch(dashboardSlotsProvider); + final config = ref.watch(dashboardConfigProvider); + final units = ref.watch(unitPrefsProvider); - return Container( - color: const Color(0xFF0A0A0A), - child: SingleChildScrollView( - child: Column( - children: [ + final tiles = Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Column( + children: [ + // Left column is values 0-4, right column 5-9, matching landscape. + for (int row = 0; row < 5; row++) 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: EdgeInsets.only(top: row == 0 ? 0 : _gap), + child: SizedBox( + height: _tileHeight, + child: Row( + children: [ + for (final i in [row, row + 5]) ...[ + if (i != row) const SizedBox(width: _gap), + Expanded( + child: SensorTile( + sensorId: config.values[i], + state: state, + units: units, + onLongPress: () => + editDashboardValue(context, ref, i), + ), + ), + ], + ], + ), ), ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: FlagRow(state: state, accentColor: colors.accent), - ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: GridView.count( - shrinkWrap: true, - 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); - }, - ), + return Container( + color: c.background, + child: LayoutBuilder(builder: (context, box) { + const tilesHeight = 5 * _tileHeight + 4 * _gap + 8; + final gaugeSize = math.max( + 140.0, + math.min(box.maxWidth - 32, box.maxHeight - tilesHeight - _flagsHeight), + ); + // Scrolls only if the screen is too short even for a small gauge. + return SingleChildScrollView( + child: Column( + children: [ + SizedBox( + height: gaugeSize, + child: Center(child: MainGauge(state: state)), + ), + Padding( + padding: + const EdgeInsets.symmetric(vertical: _gap, horizontal: 8), + child: FlagRow( + state: state, + flagIds: config.flags, + onLongPress: (i) => editDashboardFlag(context, ref, i), + ), + ), + tiles, + ], + ), + ); + }), ); } } diff --git a/lib/ui/screens/dashboard/widgets/analog_gauge.dart b/lib/ui/screens/dashboard/widgets/analog_gauge.dart index 3076b8f..6a86024 100644 --- a/lib/ui/screens/dashboard/widgets/analog_gauge.dart +++ b/lib/ui/screens/dashboard/widgets/analog_gauge.dart @@ -8,6 +8,14 @@ class AnalogGauge extends StatefulWidget { final String label; final String unit; final Color accentColor; + /// Formatted main value; defaults to [value] with no decimals. + final String? valueText; + /// Second line under the unit, e.g. the sub sensor "120 km/h". + final String? subText; + final Color foreground; + final Color muted; + final Color track; + final Color background; const AnalogGauge({ super.key, @@ -17,6 +25,12 @@ class AnalogGauge extends StatefulWidget { required this.label, required this.unit, required this.accentColor, + this.valueText, + this.subText, + this.foreground = Colors.white, + this.muted = Colors.white54, + this.track = const Color(0xFF2A2A2A), + this.background = const Color(0xFF0A0A0A), }); @override @@ -74,9 +88,14 @@ class _AnalogGaugeState extends State painter: _GaugePainter( normalizedValue: _anim.value, accentColor: widget.accentColor, - displayValue: widget.value, + valueText: widget.valueText ?? widget.value.toStringAsFixed(0), + subText: widget.subText, unit: widget.unit, label: widget.label, + foreground: widget.foreground, + muted: widget.muted, + track: widget.track, + background: widget.background, ), ), ), @@ -87,9 +106,14 @@ class _AnalogGaugeState extends State class _GaugePainter extends CustomPainter { final double normalizedValue; // 0.0–1.0 final Color accentColor; - final double displayValue; + final String valueText; + final String? subText; final String unit; final String label; + final Color foreground; + final Color muted; + final Color track; + final Color background; static const double _startAngle = 135 * pi / 180; // 7:30 position static const double _sweepAngle = 270 * pi / 180; // 270° sweep @@ -97,9 +121,14 @@ class _GaugePainter extends CustomPainter { _GaugePainter({ required this.normalizedValue, required this.accentColor, - required this.displayValue, + required this.valueText, + required this.subText, required this.unit, required this.label, + required this.foreground, + required this.muted, + required this.track, + required this.background, }); @override @@ -110,7 +139,7 @@ class _GaugePainter extends CustomPainter { // ── Background arc ────────────────────────────────────────────── final bgPaint = Paint() - ..color = const Color(0xFF2A2A2A) + ..color = track ..style = PaintingStyle.stroke ..strokeWidth = size.width * 0.06 ..strokeCap = StrokeCap.round; @@ -159,11 +188,11 @@ class _GaugePainter extends CustomPainter { void _drawTicks(Canvas canvas, Offset center, double radius, Size size) { final majorPaint = Paint() - ..color = Colors.white54 + ..color = muted ..strokeWidth = size.width * 0.012 ..strokeCap = StrokeCap.round; final minorPaint = Paint() - ..color = Colors.white24 + ..color = muted.withAlpha(90) ..strokeWidth = size.width * 0.006 ..strokeCap = StrokeCap.round; @@ -189,7 +218,7 @@ class _GaugePainter extends CustomPainter { final tailR = size.width * 0.08; final needlePaint = Paint() - ..color = Colors.white + ..color = foreground ..strokeWidth = size.width * 0.018 ..strokeCap = StrokeCap.round; @@ -203,21 +232,21 @@ class _GaugePainter extends CustomPainter { canvas.drawCircle( center, size.width * 0.04, - Paint()..color = Colors.white, + Paint()..color = foreground, ); canvas.drawCircle( center, size.width * 0.025, - Paint()..color = const Color(0xFF0A0A0A), + Paint()..color = background, ); } void _drawCenterText(Canvas canvas, Offset center, Size size) { // Value final valSpan = TextSpan( - text: displayValue.toStringAsFixed(0), + text: valueText, style: TextStyle( - color: Colors.white, + color: foreground, fontSize: size.width * 0.14, fontWeight: FontWeight.bold, letterSpacing: -1, @@ -240,7 +269,7 @@ class _GaugePainter extends CustomPainter { final unitSpan = TextSpan( text: unit, style: TextStyle( - color: Colors.white54, + color: muted, fontSize: size.width * 0.07, ), ); @@ -256,11 +285,59 @@ class _GaugePainter extends CustomPainter { size.width * 0.1 + valPainter.height + 2, ), ); + + // Label above the hub, e.g. "RPM" + final labelPainter = TextPainter( + text: TextSpan( + text: label, + style: TextStyle( + color: muted, + fontSize: size.width * 0.055, + letterSpacing: 1, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + labelPainter.paint( + canvas, + center + Offset(-labelPainter.width / 2, -size.width * 0.2), + ); + + // Sub sensor line under the unit + final sub = subText; + if (sub != null) { + final subPainter = TextPainter( + text: TextSpan( + text: sub, + style: TextStyle( + color: accentColor, + fontSize: size.width * 0.075, + fontWeight: FontWeight.w600, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + subPainter.paint( + canvas, + center + + Offset( + -subPainter.width / 2, + size.width * 0.1 + + valPainter.height + + unitPainter.height + + size.width * 0.02, + ), + ); + } } @override bool shouldRepaint(_GaugePainter old) => old.normalizedValue != normalizedValue || old.accentColor != accentColor || - old.displayValue != displayValue; + old.valueText != valueText || + old.subText != subText || + old.label != label || + old.unit != unit || + old.foreground != foreground; } diff --git a/lib/ui/screens/dashboard/widgets/dashboard_common.dart b/lib/ui/screens/dashboard/widgets/dashboard_common.dart new file mode 100644 index 0000000..603f33e --- /dev/null +++ b/lib/ui/screens/dashboard/widgets/dashboard_common.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/sensor_defs.dart'; +import '../../../../core/models/units.dart'; +import '../../../../core/protocol/sensor_state.dart'; +import '../../../../core/providers/settings_provider.dart'; +import '../../../theme/app_colors.dart'; +import '../dashboard_slots_provider.dart'; +import 'analog_gauge.dart'; +import 'sensor_picker_sheet.dart'; + +/// Gauge for the selectable main sensor with the sub sensor as a second +/// line. Long-press to change either. +class MainGauge extends ConsumerWidget { + final SensorState state; + + const MainGauge({super.key, required this.state}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = AppColors.of(context); + final config = ref.watch(dashboardConfigProvider); + final units = ref.watch(unitPrefsProvider); + final main = sensorView(config.main, units); + final sub = sensorView(config.sub, units); + final mainBase = sensorValue(state, config.main); + final subBase = sensorValue(state, config.sub); + + return GestureDetector( + onLongPress: () => _editGauge(context, ref), + child: AnalogGauge( + value: main.convert(mainBase), + minValue: main.min, + maxValue: main.max, + label: main.name.toUpperCase(), + unit: main.unit, + valueText: main.format(mainBase), + subText: '${sub.format(subBase)} ${sub.unit}'.trim(), + accentColor: c.accent, + foreground: c.onSurface, + muted: c.onSurfaceMuted, + track: c.track, + background: c.background, + ), + ); + } + + Future _editGauge(BuildContext context, WidgetRef ref) async { + final which = await showPickerSheet( + context, + title: 'GAUGE', + currentId: '', + items: const [ + PickerItem('main', 'Change main sensor'), + PickerItem('sub', 'Change sub sensor'), + ], + ); + if (which == null || !context.mounted) return; + final config = ref.read(dashboardConfigProvider); + final id = await pickSensor( + context, + title: which == 'main' ? 'MAIN SENSOR' : 'SUB SENSOR', + currentId: which == 'main' ? config.main : config.sub, + units: ref.read(unitPrefsProvider), + ); + if (id == null) return; + final n = ref.read(dashboardConfigProvider.notifier); + which == 'main' ? n.setMain(id) : n.setSub(id); + } +} + +Future editDashboardValue( + BuildContext context, WidgetRef ref, int index) async { + final id = await pickSensor( + context, + title: 'SELECT SENSOR', + currentId: ref.read(dashboardConfigProvider).values[index], + units: ref.read(unitPrefsProvider), + ); + if (id != null) { + ref.read(dashboardConfigProvider.notifier).setValue(index, id); + } +} + +Future editDashboardFlag( + BuildContext context, WidgetRef ref, int index) async { + final id = await pickFlag( + context, + currentId: ref.read(dashboardConfigProvider).flags[index], + ); + if (id != null) { + ref.read(dashboardConfigProvider.notifier).setFlag(index, id); + } +} diff --git a/lib/ui/screens/dashboard/widgets/flag_pill.dart b/lib/ui/screens/dashboard/widgets/flag_pill.dart index efed949..532bab5 100644 --- a/lib/ui/screens/dashboard/widgets/flag_pill.dart +++ b/lib/ui/screens/dashboard/widgets/flag_pill.dart @@ -1,5 +1,31 @@ import 'package:flutter/material.dart'; +import '../../../../core/models/sensor_defs.dart'; import '../../../../core/protocol/sensor_state.dart'; +import '../../../theme/app_colors.dart'; + +/// Colour of an active flag. Faults red, warnings amber, status green/blue. +Color flagColor(String id) { + switch (id) { + case 'mil': + case 'knock': + case 'o2error': + case 'iaterror': + case 'ecterror': + return Colors.red; + case 'revlimit': + case 'igncut': + case 'boostcut': + case 'launch': + case 'launchretard': + case 'shiftcut': + return Colors.orange; + case 'vtec': + case 'vtp': + return Colors.blue; + default: + return Colors.green; + } +} class FlagPill extends StatelessWidget { final String label; @@ -15,51 +41,65 @@ class FlagPill extends StatelessWidget { @override Widget build(BuildContext context) { + final c = AppColors.of(context); return AnimatedContainer( duration: const Duration(milliseconds: 100), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + height: 30, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( - color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(20), + color: active ? color.withAlpha(200) : c.surface, + borderRadius: BorderRadius.circular(6), border: Border.all( - color: active ? color : Colors.white12, - width: 1.5, + color: active ? color : c.accent.withAlpha(90), + width: 1.2, ), ), - child: Text( - label, - style: TextStyle( - color: active ? color : Colors.grey, - fontSize: 11, - fontWeight: active ? FontWeight.bold : FontWeight.normal, - letterSpacing: 1, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + style: TextStyle( + color: active ? Colors.white : c.onSurfaceMuted, + fontSize: 12, + fontWeight: active ? FontWeight.bold : FontWeight.normal, + ), ), ), ); } } -/// Row of all flag pills derived from a SensorState. +/// The 4 selectable flags of the dashboard. Long-press a pill to change it. class FlagRow extends StatelessWidget { final SensorState state; - final Color accentColor; + final List flagIds; + final void Function(int index) onLongPress; - const FlagRow({super.key, required this.state, required this.accentColor}); + const FlagRow({ + super.key, + required this.state, + required this.flagIds, + required this.onLongPress, + }); @override Widget build(BuildContext context) { - return Wrap( - spacing: 6, - runSpacing: 6, - alignment: WrapAlignment.center, + return Row( 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 PUMP', active: state.fuelPump, color: Colors.cyan), - 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), + for (var i = 0; i < flagIds.length; i++) ...[ + if (i > 0) const SizedBox(width: 6), + Expanded( + child: GestureDetector( + onLongPress: () => onLongPress(i), + child: FlagPill( + label: flagDef(flagIds[i]).displayName, + active: flagDef(flagIds[i]).read(state), + color: flagColor(flagIds[i]), + ), + ), + ), + ], ], ); } diff --git a/lib/ui/screens/dashboard/widgets/sensor_picker_sheet.dart b/lib/ui/screens/dashboard/widgets/sensor_picker_sheet.dart index f82a350..1ec01f4 100644 --- a/lib/ui/screens/dashboard/widgets/sensor_picker_sheet.dart +++ b/lib/ui/screens/dashboard/widgets/sensor_picker_sheet.dart @@ -1,18 +1,77 @@ import 'package:flutter/material.dart'; import '../../../../core/models/sensor_defs.dart'; +import '../../../../core/models/units.dart'; +import '../../../theme/app_colors.dart'; -class SensorPickerSheet extends StatelessWidget { +/// One choosable entry in [showPickerSheet]. +class PickerItem { + final String id; + final String title; + final String? subtitle; + const PickerItem(this.id, this.title, [this.subtitle]); +} + +/// Bottom sheet listing [items]; resolves to the chosen id, or null. +Future showPickerSheet( + BuildContext context, { + required String title, + required List items, + required String currentId, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _PickerSheet( + title: title, + items: items, + currentId: currentId, + ), + ); +} + +/// Picker over every analog sensor, showing the unit currently in use. +Future pickSensor( + BuildContext context, { + required String title, + required String currentId, + required UnitPrefs units, +}) { + return showPickerSheet( + context, + title: title, + currentId: currentId, + items: [ + for (final d in sensorDefs) + PickerItem(d.id, d.displayName, sensorView(d.id, units).unit), + ], + ); +} + +/// Picker over every flag. +Future pickFlag(BuildContext context, {required String currentId}) { + return showPickerSheet( + context, + title: 'SELECT FLAG', + currentId: currentId, + items: [for (final f in flagDefs) PickerItem(f.id, f.displayName)], + ); +} + +class _PickerSheet extends StatelessWidget { + final String title; + final List items; final String currentId; - final void Function(String id) onSelected; - const SensorPickerSheet({ - super.key, + const _PickerSheet({ + required this.title, + required this.items, required this.currentId, - required this.onSelected, }); @override Widget build(BuildContext context) { + final c = AppColors.of(context); return DraggableScrollableSheet( expand: false, initialChildSize: 0.6, @@ -20,58 +79,61 @@ class SensorPickerSheet extends StatelessWidget { maxChildSize: 0.9, builder: (context, scrollController) { return Container( - decoration: const BoxDecoration( - color: Color(0xFF1A1A1A), - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + decoration: BoxDecoration( + color: c.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), ), child: Column( children: [ const SizedBox(height: 8), Container( - width: 40, height: 4, + width: 40, + height: 4, decoration: BoxDecoration( - color: Colors.white24, + color: c.onSurfaceMuted.withAlpha(90), borderRadius: BorderRadius.circular(2), ), ), - const Padding( - padding: EdgeInsets.all(16), + Padding( + padding: const EdgeInsets.all(16), child: Text( - 'SELECT SENSOR', + title, style: TextStyle( - color: Colors.white70, + color: c.onSurfaceMuted, fontSize: 12, letterSpacing: 2, fontWeight: FontWeight.w600, ), ), ), - const Divider(height: 1, color: Colors.white10), + Divider(height: 1, color: c.divider), Expanded( child: ListView.builder( controller: scrollController, - itemCount: sensorDefs.length, + itemCount: items.length, itemBuilder: (context, i) { - final def = sensorDefs[i]; - final isSelected = def.id == currentId; + final item = items[i]; + final isSelected = item.id == currentId; return ListTile( title: Text( - def.displayName, + item.title, style: TextStyle( - color: isSelected ? Colors.white : Colors.white70, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, + color: c.onSurface, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, ), ), - subtitle: Text( - def.unit, - style: const TextStyle(color: Colors.grey, fontSize: 12), - ), + subtitle: item.subtitle == null + ? null + : Text( + item.subtitle!, + style: TextStyle( + color: c.onSurfaceMuted, fontSize: 12), + ), trailing: isSelected - ? const Icon(Icons.check, color: Colors.green, size: 18) + ? Icon(Icons.check, color: c.accent, size: 18) : null, - onTap: () => onSelected(def.id), + onTap: () => Navigator.pop(context, item.id), ); }, ), diff --git a/lib/ui/screens/dashboard/widgets/sensor_tile.dart b/lib/ui/screens/dashboard/widgets/sensor_tile.dart index b2ff025..c245eb5 100644 --- a/lib/ui/screens/dashboard/widgets/sensor_tile.dart +++ b/lib/ui/screens/dashboard/widgets/sensor_tile.dart @@ -1,128 +1,139 @@ import 'package:flutter/material.dart'; import '../../../../core/models/sensor_defs.dart'; +import '../../../../core/models/units.dart'; import '../../../../core/protocol/sensor_state.dart'; +import '../../../theme/app_colors.dart'; +/// One selectable dashboard value: "unit (name)" caption over a large +/// number, as in the requirement layouts. Long-press to change the sensor. class SensorTile extends StatelessWidget { - final int slotIndex; final String sensorId; final SensorState state; - final Color accentColor; - final VoidCallback onTap; + final UnitPrefs units; + final VoidCallback onLongPress; const SensorTile({ super.key, - required this.slotIndex, required this.sensorId, required this.state, - required this.accentColor, - required this.onTap, + required this.units, + required this.onLongPress, }); @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); + final c = AppColors.of(context); + final view = sensorView(sensorId, units); + final base = sensorValue(state, sensorId); + final fraction = view.fraction(base); + final caption = + view.unit.isEmpty ? view.name : '${view.unit} (${view.name})'; 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, - ), + onLongPress: onLongPress, + child: LayoutBuilder( + builder: (context, box) { + final valueText = Text( + view.format(base), + style: TextStyle( + color: fraction > 0.9 ? Colors.red : c.onSurface, + fontSize: 26, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + letterSpacing: -0.5, ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, + ); + final captionText = Text( + caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.onSurfaceMuted, + fontSize: 10, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w600, + ), + ); + // Short tiles (5 per side in landscape): caption beside the value + // so the number keeps its size. + if (box.maxHeight < 56) { + return Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: c.divider), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Row( + children: [ + Expanded(flex: 4, child: captionText), + Expanded( + flex: 5, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: valueText, + ), + ), + ], + ), + ); + } + final showBar = box.maxHeight >= 64; + return Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: c.divider), + ), + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - _format(value, sensorId), - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - letterSpacing: -0.5, + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.onSurfaceMuted, + fontSize: 10, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w600, ), ), - const SizedBox(width: 3), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - def.unit, - style: TextStyle(color: accentColor, fontSize: 11), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + view.format(base), + style: TextStyle( + color: fraction > 0.9 ? Colors.red : c.onSurface, + fontSize: 26, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + letterSpacing: -0.5, + ), + ), ), ), + if (showBar) + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: fraction, + minHeight: 3, + backgroundColor: c.track, + valueColor: AlwaysStoppedAnimation( + fraction > 0.9 ? Colors.red : c.accent, + ), + ), + ), ], ), - // 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 index 08fc743..0491302 100644 --- a/lib/ui/screens/datalog/datalog_list_screen.dart +++ b/lib/ui/screens/datalog/datalog_list_screen.dart @@ -42,7 +42,7 @@ class DatalogListScreen extends ConsumerWidget { : ListView.separated( itemCount: sessions.length, separatorBuilder: (_, __) => - const Divider(height: 1, color: Colors.white10), + const Divider(height: 1), itemBuilder: (context, i) => _SessionTile(session: sessions[i]), ), @@ -115,8 +115,11 @@ class _SessionTile extends ConsumerWidget { onTap: () async { await ref .read(playbackProvider.notifier) - .loadSession(session.id!); + .loadSession(session.id!, session.ecuType); ref.read(playbackProvider.notifier).play(); + // Back to the dashboard: the displays now show the recording, + // with the playback bar for navigation. + if (context.mounted) Navigator.pop(context); }, ), ); diff --git a/lib/ui/screens/datalog/datalog_playback_bar.dart b/lib/ui/screens/datalog/datalog_playback_bar.dart index 4eafde2..e915aae 100644 --- a/lib/ui/screens/datalog/datalog_playback_bar.dart +++ b/lib/ui/screens/datalog/datalog_playback_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/datalog/datalog_player.dart'; import '../../../core/providers/datalog_provider.dart'; +import '../../theme/app_colors.dart'; class DatalogPlaybackBar extends ConsumerWidget { final PlaybackState state; @@ -11,9 +12,10 @@ class DatalogPlaybackBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(playbackProvider.notifier); final isPlaying = state.status == PlaybackStatus.playing; + final c = AppColors.of(context); return Container( - color: const Color(0xFF1A1A1A), + color: c.surface, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, @@ -41,7 +43,7 @@ class DatalogPlaybackBar extends ConsumerWidget { Text( '${state.position} / ${state.total}', style: - const TextStyle(color: Colors.grey, fontSize: 11), + TextStyle(color: c.onSurfaceMuted, fontSize: 11), ), // Play / Pause / Stop Row( @@ -69,8 +71,7 @@ class DatalogPlaybackBar extends ConsumerWidget { value: state.speed, isDense: true, underline: const SizedBox(), - style: const TextStyle( - color: Colors.white70, fontSize: 12), + style: TextStyle(color: c.onSurface, fontSize: 12), items: const [ DropdownMenuItem(value: 0.5, child: Text('0.5×')), DropdownMenuItem(value: 1.0, child: Text('1×')), diff --git a/lib/ui/screens/dtc/dtc_screen.dart b/lib/ui/screens/dtc/dtc_screen.dart index a1de0db..c70c513 100644 --- a/lib/ui/screens/dtc/dtc_screen.dart +++ b/lib/ui/screens/dtc/dtc_screen.dart @@ -4,7 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/protocol/dtc_map.dart'; import '../../../core/providers/bt_provider.dart'; import '../../../core/providers/sensor_provider.dart'; -import '../../../core/providers/settings_provider.dart'; +import '../../../core/providers/datalog_provider.dart'; +import '../../theme/app_colors.dart'; import '../../../core/bluetooth/bt_poller.dart'; import 'widgets/dtc_row.dart'; @@ -138,7 +139,8 @@ class _DtcScreenState extends ConsumerState { Widget build(BuildContext context) { final state = ref.watch(latestSensorProvider); final bt = ref.watch(btProvider); - final ecuType = ref.watch(settingsProvider).ecuType; + final ecuType = ref.watch(effectiveEcuTypeProvider); + final c = AppColors.of(context); final dtcMap = ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap; @@ -148,7 +150,7 @@ class _DtcScreenState extends ConsumerState { children: [ // Header bar Container( - color: const Color(0xFF1A1A1A), + color: c.surface, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( @@ -175,12 +177,12 @@ class _DtcScreenState extends ConsumerState { Text( ecuType == EcuType.s300 ? 'S300' : 'KPro', style: - const TextStyle(color: Colors.grey, fontSize: 12), + TextStyle(color: c.onSurfaceMuted, fontSize: 12), ), ], ), ), - const Divider(height: 1, color: Colors.white10), + Divider(height: 1, color: c.divider), // DTC list Expanded( @@ -201,7 +203,7 @@ class _DtcScreenState extends ConsumerState { : ListView.separated( itemCount: active.length, separatorBuilder: (_, __) => - const Divider(height: 1, color: Colors.white10), + Divider(height: 1, color: c.divider), itemBuilder: (context, i) { final full = active[i]; // "P0130 — O2 Sensor (front)" final parts = full.split(' — '); diff --git a/lib/ui/screens/dtc/widgets/dtc_row.dart b/lib/ui/screens/dtc/widgets/dtc_row.dart index 2ac58f3..bb342af 100644 --- a/lib/ui/screens/dtc/widgets/dtc_row.dart +++ b/lib/ui/screens/dtc/widgets/dtc_row.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../../theme/app_colors.dart'; class DtcRow extends StatelessWidget { final String code; @@ -34,7 +35,8 @@ class DtcRow extends StatelessWidget { Expanded( child: Text( description, - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: AppColors.of(context).onSurface, fontSize: 13), ), ), ], diff --git a/lib/ui/screens/graph/graph_screen.dart b/lib/ui/screens/graph/graph_screen.dart index 8e10ca4..462f332 100644 --- a/lib/ui/screens/graph/graph_screen.dart +++ b/lib/ui/screens/graph/graph_screen.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/models/sensor_defs.dart'; +import '../../../core/models/units.dart'; import '../../../core/providers/sensor_history_provider.dart'; -import '../../../core/providers/theme_provider.dart'; +import '../../../core/providers/settings_provider.dart'; import '../../../ui/theme/app_colors.dart'; +import '../dashboard/widgets/sensor_picker_sheet.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'], + (ref) => ['rpm', 'map', 'tps', 'ect'], ); class GraphScreen extends ConsumerWidget { @@ -18,7 +19,8 @@ class GraphScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final history = ref.watch(sensorHistoryProvider); - final colors = AppColors.forTheme(ref.watch(themeProvider)); + final colors = AppColors.of(context); + final units = ref.watch(unitPrefsProvider); final window = ref.watch(_graphWindowProvider); final sensors = ref.watch(_graphSensorsProvider); @@ -26,12 +28,12 @@ class GraphScreen extends ConsumerWidget { children: [ // Time selector bar Container( - color: const Color(0xFF1A1A1A), + color: colors.surface, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ - const Text('Window:', - style: TextStyle(color: Colors.grey, fontSize: 12)), + Text('Window:', + style: TextStyle(color: colors.onSurfaceMuted, fontSize: 12)), const SizedBox(width: 8), GraphTimeSelector( windowSeconds: window, @@ -59,8 +61,8 @@ class GraphScreen extends ConsumerWidget { 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)), + Text('No sensors selected', + style: TextStyle(color: colors.onSurfaceMuted)), const SizedBox(height: 8), ElevatedButton.icon( onPressed: () => _addSensor(context, ref, sensors), @@ -73,10 +75,9 @@ class GraphScreen extends ConsumerWidget { : ListView.builder( itemCount: sensors.length, itemBuilder: (context, i) => SensorGraphPanel( - sensorId: sensors[i], + view: sensorView(sensors[i], units), history: history, windowSeconds: window, - accentColor: colors.accent, onRemove: () { final next = List.from(sensors)..removeAt(i); ref.read(_graphSensorsProvider.notifier).state = next; @@ -88,49 +89,15 @@ class GraphScreen extends ConsumerWidget { ); } - 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); - }, - ); - }), - ], - ), + Future _addSensor( + BuildContext context, WidgetRef ref, List current) async { + final id = await pickSensor( + context, + title: 'ADD SENSOR TO GRAPH', + currentId: '', + units: ref.read(unitPrefsProvider), ); + if (id == null || current.contains(id)) return; + ref.read(_graphSensorsProvider.notifier).state = [...current, id]; } } diff --git a/lib/ui/screens/graph/widgets/graph_time_selector.dart b/lib/ui/screens/graph/widgets/graph_time_selector.dart index b872de0..6eacd9c 100644 --- a/lib/ui/screens/graph/widgets/graph_time_selector.dart +++ b/lib/ui/screens/graph/widgets/graph_time_selector.dart @@ -30,13 +30,13 @@ class GraphTimeSelector extends StatelessWidget { color: selected ? accent.withAlpha(30) : Colors.transparent, borderRadius: BorderRadius.circular(8), border: Border.all( - color: selected ? accent : Colors.white24, + color: selected ? accent : Theme.of(context).dividerColor, ), ), child: Text( s >= 60 ? '${s ~/ 60}m' : '${s}s', style: TextStyle( - color: selected ? accent : Colors.white54, + color: selected ? accent : Theme.of(context).hintColor, fontSize: 12, fontWeight: selected ? FontWeight.bold : FontWeight.normal, ), diff --git a/lib/ui/screens/graph/widgets/sensor_graph_panel.dart b/lib/ui/screens/graph/widgets/sensor_graph_panel.dart index 3c0dcd8..bbe00b5 100644 --- a/lib/ui/screens/graph/widgets/sensor_graph_panel.dart +++ b/lib/ui/screens/graph/widgets/sensor_graph_panel.dart @@ -1,133 +1,203 @@ +import 'dart:math' as math; + import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -import '../../../../core/models/sensor_defs.dart'; +import '../../../../core/models/units.dart'; import '../../../../core/providers/sensor_history_provider.dart'; +import '../../../theme/app_colors.dart'; +/// One graph (requirements): line over the selected time window with the +/// window's max/min top-left, current value and name bottom-left, value +/// axis on the right, seconds-ago axis along the bottom, and a touch cursor +/// that reads out the value under the finger. class SensorGraphPanel extends StatelessWidget { - final String sensorId; + final SensorView view; final SensorHistory history; final int windowSeconds; - final Color accentColor; final VoidCallback onRemove; const SensorGraphPanel({ super.key, - required this.sensorId, + required this.view, 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); + final c = AppColors.of(context); + final raw = history.get(view.def.id); + final now = raw.isEmpty ? 0.0 : raw.last.x; + // x = seconds before the latest sample (0 at the right edge). + final spots = [ + for (final s in raw) + if (now - s.x <= windowSeconds) FlSpot(s.x - now, view.convert(s.y)), + ]; - // Slice to window - final spots = _window(allSpots, windowSeconds.toDouble()); + double? lo, hi; + for (final s in spots) { + lo = lo == null ? s.y : math.min(lo, s.y); + hi = hi == null ? s.y : math.max(hi, s.y); + } + // Y axis follows the data in the window (like the reference), with a + // margin, and never collapses to zero height for a steady value. + var minY = view.min, maxY = view.max; + if (lo != null && hi != null) { + final pad = math.max((hi - lo) * 0.15, (view.max - view.min) * 0.02); + minY = lo - pad; + maxY = hi + pad; + } + final muted = TextStyle(color: c.onSurfaceMuted, fontSize: 11); + String f(double v) => v.toStringAsFixed(view.decimals); return Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - padding: const EdgeInsets.fromLTRB(8, 10, 12, 8), + height: 170, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.fromLTRB(8, 6, 4, 4), decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(10), + color: c.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: c.divider), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( 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, + Padding( + padding: const EdgeInsets.only(bottom: 4), child: spots.length < 2 - ? const Center( - child: Text('Waiting for data…', - style: TextStyle(color: Colors.white30, - fontSize: 12))) + ? Center(child: Text('Waiting for data…', style: muted)) : LineChart( LineChartData( + minX: -windowSeconds.toDouble(), + maxX: 0, + minY: minY, + maxY: maxY, + clipData: const FlClipData.all(), gridData: FlGridData( show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (_) => FlLine( - color: Colors.white10, - strokeWidth: 1, - ), + getDrawingHorizontalLine: (_) => + FlLine(color: c.divider, strokeWidth: 1), + getDrawingVerticalLine: (_) => + FlLine(color: c.divider, strokeWidth: 1), ), borderData: FlBorderData(show: false), - titlesData: const FlTitlesData(show: false), - minY: def.min.toDouble(), - maxY: def.max.toDouble(), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + leftTitles: const AxisTitles(), + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (v, meta) => Text( + _compact(v), + style: muted.copyWith(fontSize: 9), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 16, + interval: windowSeconds / 6, + getTitlesWidget: (v, meta) => Text( + (-v).round().toString(), + style: muted.copyWith(fontSize: 9), + ), + ), + ), + ), + lineTouchData: LineTouchData( + getTouchedSpotIndicator: (bar, indexes) => [ + for (final _ in indexes) + const TouchedSpotIndicatorData( + FlLine(color: Colors.red, strokeWidth: 1.5), + FlDotData(show: true), + ), + ], + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => Colors.red, + tooltipPadding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 2), + getTooltipItems: (touched) => [ + for (final t in touched) + LineTooltipItem( + f(t.y), + const TextStyle( + color: Colors.white, fontSize: 11), + ), + ], + ), + ), lineBarsData: [ LineChartBarData( spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: accentColor, + isCurved: false, + color: c.accent, barWidth: 1.5, dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - color: accentColor.withAlpha(25), - ), ), ], ), duration: Duration.zero, ), ), + // Window max / min + Positioned( + left: 4, + top: 0, + child: IgnorePointer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(hi == null ? '-' : f(hi), style: muted), + Text(lo == null ? '-' : f(lo), style: muted), + ], + ), + ), + ), + // Name + current value + Positioned( + left: 4, + bottom: 18, + child: IgnorePointer( + child: RichText( + text: TextSpan(children: [ + TextSpan( + text: '${view.name} ', + style: TextStyle(color: c.accent, fontSize: 14), + ), + TextSpan( + text: raw.isEmpty ? '-' : view.format(raw.last.y), + style: TextStyle( + color: c.onSurface, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: ' ${view.unit}', + style: TextStyle(color: c.onSurfaceMuted, fontSize: 12), + ), + ]), + ), + ), + ), + Positioned( + right: 0, + top: 0, + child: GestureDetector( + onTap: onRemove, + child: Icon(Icons.close, size: 16, color: c.onSurfaceMuted), + ), + ), ], ), ); } - 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(); + static String _compact(double v) { + if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}k'; + if (v.abs() >= 10 || v == v.roundToDouble()) return v.toStringAsFixed(0); + return v.toStringAsFixed(1); } - - 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 index 472ab27..39adbc0 100644 --- a/lib/ui/screens/sensor_list/sensor_list_screen.dart +++ b/lib/ui/screens/sensor_list/sensor_list_screen.dart @@ -1,29 +1,37 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/sensor_defs.dart'; +import '../../../core/models/units.dart'; import '../../../core/providers/sensor_provider.dart'; -import '../../../core/providers/theme_provider.dart'; -import '../../../ui/theme/app_colors.dart'; +import '../../../core/providers/settings_provider.dart'; import 'widgets/sensor_row.dart'; +/// All sensors as a list (requirements): 1 column on a phone in portrait, +/// 2 columns on a tablet in portrait and in landscape. 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)); + final units = ref.watch(unitPrefsProvider); + final media = MediaQuery.of(context); + final isTablet = media.size.shortestSide >= 600; + final isLandscape = media.orientation == Orientation.landscape; + final columns = (isTablet || isLandscape) ? 2 : 1; - return ListView.separated( + return GridView.builder( itemCount: sensorDefs.length, - separatorBuilder: (_, __) => - const Divider(height: 1, color: Colors.white10), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisExtent: 72, + crossAxisSpacing: 8, + ), itemBuilder: (context, i) { - final def = sensorDefs[i]; + final id = sensorDefs[i].id; return SensorRow( - def: def, - value: sensorValue(state, def.id), - accentColor: colors.accent, + view: sensorView(id, units), + baseValue: sensorValue(state, id), ); }, ); diff --git a/lib/ui/screens/sensor_list/widgets/sensor_row.dart b/lib/ui/screens/sensor_list/widgets/sensor_row.dart index 0af8d90..fc8d907 100644 --- a/lib/ui/screens/sensor_list/widgets/sensor_row.dart +++ b/lib/ui/screens/sensor_list/widgets/sensor_row.dart @@ -1,130 +1,129 @@ import 'package:flutter/material.dart'; -import '../../../../core/models/sensor_def.dart'; -import '../../../../core/protocol/sensor_state.dart'; +import '../../../../core/models/units.dart'; +import '../../../theme/app_colors.dart'; +/// Sensor list entry (requirements): name and unit, min/max of the range, +/// a segmented bar gauge and the numeric value. class SensorRow extends StatelessWidget { - final SensorDef def; - final double value; - final Color accentColor; + final SensorView view; + final double baseValue; - const SensorRow({ - super.key, - required this.def, - required this.value, - required this.accentColor, - }); + const SensorRow({super.key, required this.view, required this.baseValue}); @override Widget build(BuildContext context) { - final normalized = - ((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0); - final isHigh = normalized > 0.8; + final c = AppColors.of(context); + final fraction = view.fraction(baseValue); + final isHigh = fraction > 0.9; + final rangeStyle = TextStyle(color: c.onSurfaceMuted, fontSize: 10); + String fmt(double v) => v.toStringAsFixed(view.decimals > 1 ? 1 : 0); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - child: Row( + return Container( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 6), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: c.divider)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, 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), - ), - ), + Row( + children: [ + Expanded( + child: Text( + view.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.accent, + fontSize: 14, + fontWeight: FontWeight.w600, ), - ], + ), ), - ), + Text(view.unit, + style: TextStyle(color: c.onSurfaceMuted, fontSize: 13)), + ], ), - 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, + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text(fmt(view.min), style: rangeStyle), + const Spacer(), + Text(fmt(view.max), style: rangeStyle), + ], ), - ), - TextSpan( - text: ' ${def.unit}', + const SizedBox(height: 2), + _SegmentBar( + fraction: fraction, + color: isHigh ? Colors.red : c.accent, + track: c.track, + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 90, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + view.format(baseValue), style: TextStyle( - color: accentColor, - fontSize: 11, + color: isHigh ? Colors.red : c.onSurface, + fontSize: 24, + fontWeight: FontWeight.bold, ), ), - ], + ), ), - ), + ], ), ], ), ); } - - 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; +/// Bar made of thin segments, like the reference screenshot. +class _SegmentBar extends StatelessWidget { + final double fraction; + final Color color; + final Color track; + + const _SegmentBar({ + required this.fraction, + required this.color, + required this.track, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 14, + child: LayoutBuilder(builder: (context, box) { + const segWidth = 3.0; + const gap = 2.0; + final count = (box.maxWidth / (segWidth + gap)).floor().clamp(1, 400); + final lit = (fraction * count).round(); + return Row( + children: [ + for (var i = 0; i < count; i++) + Container( + width: segWidth, + margin: const EdgeInsets.only(right: gap), + color: i < lit ? color : track, + ), + ], + ); + }), + ); } } diff --git a/lib/ui/screens/settings/settings_screen.dart b/lib/ui/screens/settings/settings_screen.dart index add4f67..2f8c813 100644 --- a/lib/ui/screens/settings/settings_screen.dart +++ b/lib/ui/screens/settings/settings_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/bluetooth/bt_poller.dart'; +import '../../../core/models/units.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'; +import '../debug/raw_frame_debug_screen.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -15,7 +17,9 @@ class SettingsScreen extends ConsumerWidget { final settings = ref.watch(settingsProvider); final themeVariant = ref.watch(themeProvider); final bt = ref.watch(btProvider); - final colors = AppColors.forTheme(themeVariant); + final colors = AppColors.of(context); + final units = ref.watch(unitPrefsProvider); + final unitsN = ref.read(unitPrefsProvider.notifier); return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -48,7 +52,7 @@ class SettingsScreen extends ConsumerWidget { MaterialPageRoute(builder: (_) => const BtPickerScreen()), ), ), - const Divider(height: 1, color: Colors.white10), + const Divider(height: 1), // ── ECU Protocol ───────────────────────────────────────── _SectionHeader('ECU PROTOCOL'), @@ -74,7 +78,7 @@ class SettingsScreen extends ConsumerWidget { if (v != null) ref.read(settingsProvider.notifier).setEcuType(v); }, ), - const Divider(height: 1, color: Colors.white10), + const Divider(height: 1), // ── Polling Interval ───────────────────────────────────── _SectionHeader('POLLING INTERVAL'), @@ -91,7 +95,36 @@ class SettingsScreen extends ConsumerWidget { } }, )), - const Divider(height: 1, color: Colors.white10), + const Divider(height: 1), + + // ── Units ──────────────────────────────────────────────── + _SectionHeader('UNITS'), + _UnitRow( + label: 'Speed', + value: units.speed, + options: const {SpeedUnit.kmh: 'km/h', SpeedUnit.mph: 'mph'}, + onChanged: unitsN.setSpeed, + ), + _UnitRow( + label: 'Temperature', + value: units.temp, + options: const { + TempUnit.celsius: '°C', + TempUnit.fahrenheit: '°F', + }, + onChanged: unitsN.setTemp, + ), + _UnitRow( + label: 'Pressure', + value: units.pressure, + options: const { + PressureUnit.kpa: 'kPa', + PressureUnit.bar: 'bar', + PressureUnit.psi: 'psi', + }, + onChanged: unitsN.setPressure, + ), + const Divider(height: 1), // ── Theme ──────────────────────────────────────────────── _SectionHeader('THEME'), @@ -112,7 +145,20 @@ class SettingsScreen extends ConsumerWidget { }, ); }), - const Divider(height: 1, color: Colors.white10), + const Divider(height: 1), + + // ── Diagnostics ────────────────────────────────────────── + _SectionHeader('DIAGNOSTICS'), + ListTile( + leading: Icon(Icons.bug_report_outlined, color: colors.accent), + title: const Text('ECU frame debug & app log'), + trailing: const Icon(Icons.arrow_forward_ios, size: 14), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()), + ), + ), + const Divider(height: 1), // ── App info ───────────────────────────────────────────── _SectionHeader('ABOUT'), @@ -149,3 +195,39 @@ class _SectionHeader extends StatelessWidget { ); } } + +/// One unit choice (e.g. Speed: km/h | mph). +class _UnitRow extends StatelessWidget { + final String label; + final T value; + final Map options; + final void Function(T) onChanged; + + const _UnitRow({ + required this.label, + required this.value, + required this.options, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Expanded(child: Text(label)), + SegmentedButton( + showSelectedIcon: false, + segments: [ + for (final e in options.entries) + ButtonSegment(value: e.key, label: Text(e.value)), + ], + selected: {value}, + onSelectionChanged: (s) => onChanged(s.first), + ), + ], + ), + ); + } +} diff --git a/lib/ui/theme/app_colors.dart b/lib/ui/theme/app_colors.dart index 05fcc50..2ed24da 100644 --- a/lib/ui/theme/app_colors.dart +++ b/lib/ui/theme/app_colors.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; import '../../core/providers/theme_provider.dart'; -class AppColors { +/// App palette for one theme variant. Registered as a [ThemeExtension] so +/// widgets read it with [AppColors.of] and follow light/dark automatically. +class AppColors extends ThemeExtension { final Color accent; final Color background; final Color surface; final Color surfaceVariant; final Color onSurface; final Color onSurfaceMuted; + final Color divider; + final Color track; // empty part of bars and gauge arcs const AppColors._({ required this.accent, @@ -16,43 +20,36 @@ class AppColors { required this.surfaceVariant, required this.onSurface, required this.onSurfaceMuted, + required this.divider, + required this.track, }); - static const redDark = AppColors._( + static const _dark = AppColors._( accent: Color(0xFFFF4444), background: Color(0xFF0A0A0A), surface: Color(0xFF1A1A1A), surfaceVariant: Color(0xFF242424), onSurface: Colors.white, - onSurfaceMuted: Colors.grey, + onSurfaceMuted: Color(0xFF9E9E9E), + divider: Color(0x1AFFFFFF), + track: Color(0xFF2A2A2A), ); - static const redLight = AppColors._( + static const _light = AppColors._( accent: Color(0xFFCC0000), background: Color(0xFFF2F2F2), surface: Color(0xFFFFFFFF), surfaceVariant: Color(0xFFEAEAEA), onSurface: Color(0xFF111111), - onSurfaceMuted: Color(0xFF777777), + onSurfaceMuted: Color(0xFF6E6E6E), + divider: Color(0x1F000000), + track: Color(0xFFDDDDDD), ); - 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 const redDark = _dark; + static const redLight = _light; + static final greenDark = _dark.copyWith(accent: const Color(0xFF00E676)); + static final greenLight = _light.copyWith(accent: const Color(0xFF00875A)); static AppColors forTheme(AppThemeVariant v) { switch (v) { @@ -62,4 +59,45 @@ class AppColors { case AppThemeVariant.greenLight: return greenLight; } } + + /// Palette of the current theme. + static AppColors of(BuildContext context) => + Theme.of(context).extension() ?? _dark; + + @override + AppColors copyWith({ + Color? accent, + Color? background, + Color? surface, + Color? surfaceVariant, + Color? onSurface, + Color? onSurfaceMuted, + Color? divider, + Color? track, + }) => + AppColors._( + accent: accent ?? this.accent, + background: background ?? this.background, + surface: surface ?? this.surface, + surfaceVariant: surfaceVariant ?? this.surfaceVariant, + onSurface: onSurface ?? this.onSurface, + onSurfaceMuted: onSurfaceMuted ?? this.onSurfaceMuted, + divider: divider ?? this.divider, + track: track ?? this.track, + ); + + @override + AppColors lerp(AppColors? other, double t) { + if (other == null) return this; + return AppColors._( + accent: Color.lerp(accent, other.accent, t)!, + background: Color.lerp(background, other.background, t)!, + surface: Color.lerp(surface, other.surface, t)!, + surfaceVariant: Color.lerp(surfaceVariant, other.surfaceVariant, t)!, + onSurface: Color.lerp(onSurface, other.onSurface, t)!, + onSurfaceMuted: Color.lerp(onSurfaceMuted, other.onSurfaceMuted, t)!, + divider: Color.lerp(divider, other.divider, t)!, + track: Color.lerp(track, other.track, t)!, + ); + } } diff --git a/lib/ui/theme/app_theme.dart b/lib/ui/theme/app_theme.dart index 98f5e64..3553110 100644 --- a/lib/ui/theme/app_theme.dart +++ b/lib/ui/theme/app_theme.dart @@ -11,6 +11,8 @@ class AppTheme { useMaterial3: true, brightness: isDark ? Brightness.dark : Brightness.light, scaffoldBackgroundColor: colors.background, + extensions: [colors], + dividerColor: colors.divider, colorScheme: ColorScheme.fromSeed( seedColor: colors.accent, brightness: isDark ? Brightness.dark : Brightness.light, @@ -39,7 +41,7 @@ class AppTheme { ), ), dividerTheme: DividerThemeData( - color: colors.surfaceVariant, + color: colors.divider, thickness: 1, ), floatingActionButtonTheme: FloatingActionButtonThemeData( diff --git a/lib/ui/widgets/data_status_banner.dart b/lib/ui/widgets/data_status_banner.dart index c89788e..30a3637 100644 --- a/lib/ui/widgets/data_status_banner.dart +++ b/lib/ui/widgets/data_status_banner.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/providers/bt_provider.dart'; +import '../../core/providers/datalog_provider.dart'; /// Thin banner under the app bar that tells the driver when the numbers on /// screen are NOT live, instead of silently showing stale or default values. @@ -12,10 +13,14 @@ class DataStatusBanner extends ConsumerWidget { final status = ref.watch(btProvider.select((s) => s.status)); final data = ref.watch(btProvider.select((s) => s.dataStatus)); final error = ref.watch(btProvider.select((s) => s.error)); + final playing = ref.watch(playbackProvider.select((p) => p.isActive)); final String? msg; Color color = const Color(0xFF8A5A00); - if (status == BtStatus.reconnecting) { + if (playing) { + msg = 'Playing back a recorded session — not live data'; + color = const Color(0xFF0D47A1); + } else if (status == BtStatus.reconnecting) { msg = 'Connection interrupted — reconnecting…'; } else if (status == BtStatus.error) { msg = error ?? 'Connection lost'; diff --git a/lib/ui/widgets/main_nav.dart b/lib/ui/widgets/main_nav.dart index 71036f0..0c3941d 100644 --- a/lib/ui/widgets/main_nav.dart +++ b/lib/ui/widgets/main_nav.dart @@ -6,7 +6,8 @@ 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/datalog/datalog_playback_bar.dart'; +import '../../core/providers/datalog_provider.dart'; import '../screens/settings/settings_screen.dart'; import 'bt_status_chip.dart'; import 'data_status_banner.dart'; @@ -46,6 +47,7 @@ class MainNav extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final index = ref.watch(_navIndexProvider); + final playback = ref.watch(playbackProvider); return Scaffold( appBar: AppBar( @@ -60,14 +62,8 @@ class MainNav extends ConsumerWidget { MaterialPageRoute(builder: (_) => const DatalogListScreen()), ), ), - IconButton( - icon: const Icon(Icons.bug_report_outlined), - tooltip: 'ECU Frame Debug', - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()), - ), - ), + // Record sits in the app bar so it never covers dashboard values. + const RecordButton(), IconButton( icon: const Icon(Icons.settings_outlined), tooltip: 'Settings', @@ -78,23 +74,28 @@ class MainNav extends ConsumerWidget { ), ], ), - body: Column( - children: [ - const DataStatusBanner(), - Expanded( - child: IndexedStack( - index: index, - children: _screens, + // SafeArea: in landscape the system navigation bar sits on a side. + body: SafeArea( + top: false, + bottom: false, + child: Column( + children: [ + const DataStatusBanner(), + if (playback.isActive) DatalogPlaybackBar(state: playback), + Expanded( + child: 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 index c362c74..88b6557 100644 --- a/lib/ui/widgets/record_fab.dart +++ b/lib/ui/widgets/record_fab.dart @@ -3,8 +3,9 @@ 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}); +/// App-bar record/stop button for datalogging (shown while connected). +class RecordButton extends ConsumerWidget { + const RecordButton({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -16,9 +17,8 @@ class RecordFab extends ConsumerWidget { final isRecording = recording.isRecording; - return FloatingActionButton( - heroTag: 'record_fab', - backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary, + return IconButton( + color: isRecording ? Colors.red : null, onPressed: () async { if (isRecording) { await ref.read(recordingProvider.notifier).stopRecording(); @@ -37,11 +37,12 @@ class RecordFab extends ConsumerWidget { } }, tooltip: isRecording ? 'Stop recording' : 'Start recording', - child: AnimatedSwitcher( + icon: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: isRecording - ? const Icon(Icons.stop, key: ValueKey('stop')) - : const Icon(Icons.fiber_manual_record, key: ValueKey('rec')), + ? const Icon(Icons.stop_circle, key: ValueKey('stop')) + : const Icon(Icons.fiber_manual_record_outlined, + key: ValueKey('rec')), ), ); } diff --git a/test/units_and_flags_test.dart b/test/units_and_flags_test.dart new file mode 100644 index 0000000..3db7932 --- /dev/null +++ b/test/units_and_flags_test.dart @@ -0,0 +1,96 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hvbt_dash/core/models/sensor_defs.dart'; +import 'package:hvbt_dash/core/models/units.dart'; +import 'package:hvbt_dash/core/protocol/neg8.dart'; +import 'package:hvbt_dash/core/protocol/s300_parser.dart'; + +/// S300 frame with header, the given bytes set, and a valid checksum. +Uint8List _s300(Map bytes) { + final f = Uint8List(128); + f[0] = 0x1B; + bytes.forEach((i, v) => f[i] = v); + f[127] = calculateNeg8(f.sublist(0, 127)); + return f; +} + +void main() { + group('display units', () { + test('metric defaults are unchanged', () { + final v = sensorView('vss', const UnitPrefs()); + expect(v.unit, 'km/h'); + expect(v.convert(100), 100); + }); + + test('speed in mph', () { + final v = sensorView('vss', const UnitPrefs(speed: SpeedUnit.mph)); + expect(v.unit, 'mph'); + expect(v.convert(100), closeTo(62.14, 0.01)); + }); + + test('temperatures in °F, range converted too', () { + final v = + sensorView('ect', const UnitPrefs(temp: TempUnit.fahrenheit)); + expect(v.unit, '°F'); + expect(v.convert(100), closeTo(212, 0.001)); + expect(v.min, closeTo(-40, 0.001)); + expect(v.max, closeTo(302, 0.001)); + }); + + test('MAP and baro in bar and psi', () { + final bar = sensorView('map', const UnitPrefs(pressure: PressureUnit.bar)); + expect(bar.unit, 'bar'); + expect(bar.format(101.3), '1.01'); + final psi = sensorView('pa', const UnitPrefs(pressure: PressureUnit.psi)); + expect(psi.unit, 'psi'); + expect(psi.convert(100), closeTo(14.50, 0.01)); + }); + + test('unit choice does not touch unrelated sensors', () { + const u = UnitPrefs( + speed: SpeedUnit.mph, + temp: TempUnit.fahrenheit, + pressure: PressureUnit.psi, + ); + expect(sensorView('rpm', u).convert(3000), 3000); + expect(sensorView('bat', u).unit, 'V'); + }); + }); + + group('S300 flags', () { + test('SW3 cut/launch bits (byte 19)', () { + final s = parseS300(_s300({19: 0x20 | 0x08})); + expect(s.ignCut, isTrue); + expect(s.boostCut, isTrue); + expect(s.shiftCut, isFalse); + expect(s.launch, isFalse); + }); + + test('SW1 brake/AC/VTP (byte 17), SW5 closed loop (byte 49)', () { + final s = parseS300(_s300({17: 0x04 | 0x02 | 0x01, 49: 0x04})); + expect(s.brakeSwitch, isTrue); + expect(s.acSwitch, isTrue); + expect(s.vtp, isTrue); + expect(s.closedLoop, isTrue); + }); + + test('IAT / CLT / WBO error flags come from stored DTC bits', () { + // ERR01 bit1 = P0113 IAT, ERR00 bit5 = ECT, ERR00 bit0 = O2A + final s = parseS300(_s300({38: 0x02, 37: 0x20 | 0x01})); + expect(s.iatError, isTrue); + expect(s.ectError, isTrue); + expect(s.o2Error, isTrue); + final clean = parseS300(_s300({})); + expect(clean.iatError || clean.ectError || clean.o2Error, isFalse); + }); + + test('every flag id resolves and reads a value', () { + final s = parseS300(_s300({})); + for (final f in flagDefs) { + expect(flagDef(f.id).id, f.id); + expect(f.read(s), isA()); + } + }); + }); +}