Meet Requrements.pdf: selectable dashboard, units, list/graph, light mode, playback

Dashboard
- Selectable main sensor (gauge) + sub sensor, 10 selectable values
  (landscape 5 left + 5 right, portrait 2x5 on one screen) and 4
  selectable flags; long-press to change; persisted.
- Flags: the requirement examples (MIL, FAN Out, VTEC Out, Knock, WBO /
  IAT / CLT Error - errors derived from the stored DTC bits) plus the
  other S300 status bits (cuts, launch, closed loop, VTP, AC, brake).
- Record button moved to the app bar; debug screen moved to Settings.

Sensor list: 1 column on phone portrait, 2 on tablet/landscape; name,
unit, min/max, segmented bar and value.
Graph: auto-scaled Y, window max/min, current value, seconds axis and a
touch cursor; RPM/MAP/TPS/ECT by default.
Units: km/h|mph, degC|degF, kPa|bar|psi (display only), persisted.
Light/dark: palette is a ThemeExtension used by every screen; theme
persisted. SafeArea keeps content off the side navigation bar.
Datalog playback now drives the dashboard, list, graph and DTC screen,
with the playback bar on the main screen and seek updating the displays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-12 23:24:28 -04:00
parent ba79775fbc
commit e4bcfb57a6
38 changed files with 1718 additions and 695 deletions

View File

@ -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);
}

View File

@ -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,
});
}

View File

@ -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,
});
}

View File

@ -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<SensorDef> 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<SensorDef> 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<FlagDef> 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<FlagDef> 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);

101
lib/core/models/units.dart Normal file
View File

@ -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);
}
}

View File

@ -238,6 +238,30 @@ const Map<String, String> 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<int> 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<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {

View File

@ -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(),
);

View File

@ -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(),
);

View File

@ -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<int> 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,
);

View File

@ -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<List<DatalogSession>>((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<PlaybackState> {
PlaybackNotifier(this._ref) : super(const PlaybackState());
Future<void> 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<void> 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<PlaybackState> {
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<PlaybackState> {
Future<void> 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<PlaybackNotifier, PlaybackState>(
(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<EcuType>((ref) {
final playback = ref.watch(playbackProvider);
if (playback.isActive) return playback.ecuType;
return ref.watch(settingsProvider.select((s) => s.ecuType));
});

View File

@ -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<DecodeStats>((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<SensorState>((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:

View File

@ -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<AppSettings> {
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
(ref) => SettingsNotifier(),
);
// ─── Display units ───────────────────────────────────────────────────────────
const _unitKeys = (speed: 'unitSpeed', temp: 'unitTemp', pressure: 'unitPressure');
T? _byName<T extends Enum>(List<T> 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<UnitPrefs> {
UnitPrefsNotifier() : super(const UnitPrefs()) {
_load();
}
Future<void> _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<UnitPrefsNotifier, UnitPrefs>(
(ref) => UnitPrefsNotifier(),
);

View File

@ -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<AppThemeVariant> {
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<AppThemeVariant> {
ThemeNotifier() : super(AppThemeVariant.redDark) {
_load();
}
Future<void> _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 =

View File

@ -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(),
);
}

View File

@ -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<void>(
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),
),
),
],
),
),
),
);
}

View File

@ -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<List<String>> {
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<String>.from(state);
next[index] = sensorId;
state = next;
final String main;
final String sub;
final List<String> values;
final List<String> 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<String>? values,
List<String>? 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<DashboardConfig> {
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<void> _load() async {
try {
final p = await SharedPreferences.getInstance();
const d = DashboardConfig.defaults;
List<String> list(String key, int n, bool Function(String) valid,
List<String> 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<DashboardSlotsNotifier, List<String>>(
(ref) => DashboardSlotsNotifier(),
final dashboardConfigProvider =
StateNotifierProvider<DashboardConfigNotifier, DashboardConfig>(
(ref) => DashboardConfigNotifier(),
);

View File

@ -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<void>(
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,
],
),
);
}),
);
}
}

View File

@ -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<AnalogGauge>
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<AnalogGauge>
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;
}

View File

@ -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<void> _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<void> 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<void> 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);
}
}

View File

@ -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<String> 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]),
),
),
),
],
],
);
}

View File

@ -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<String?> showPickerSheet(
BuildContext context, {
required String title,
required List<PickerItem> items,
required String currentId,
}) {
return showModalBottomSheet<String>(
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<String?> 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<String?> 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<PickerItem> 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),
);
},
),

View File

@ -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<Color>(
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<Color>(
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;
}
}

View File

@ -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);
},
),
);

View File

@ -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×')),

View File

@ -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<DtcScreen> {
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<DtcScreen> {
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<DtcScreen> {
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<DtcScreen> {
: 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(' — ');

View File

@ -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),
),
),
],

View File

@ -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<int>((ref) => 60);
final _graphSensorsProvider = StateProvider<List<String>>(
(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<String>.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<String> current) {
showModalBottomSheet<void>(
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<void> _addSensor(
BuildContext context, WidgetRef ref, List<String> 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];
}
}

View File

@ -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,
),

View File

@ -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<FlSpot> _window(List<FlSpot> 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);
}

View File

@ -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),
);
},
);

View File

@ -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,
),
],
);
}),
);
}
}

View File

@ -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<SpeedUnit>(
label: 'Speed',
value: units.speed,
options: const {SpeedUnit.kmh: 'km/h', SpeedUnit.mph: 'mph'},
onChanged: unitsN.setSpeed,
),
_UnitRow<TempUnit>(
label: 'Temperature',
value: units.temp,
options: const {
TempUnit.celsius: '°C',
TempUnit.fahrenheit: '°F',
},
onChanged: unitsN.setTemp,
),
_UnitRow<PressureUnit>(
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<T> extends StatelessWidget {
final String label;
final T value;
final Map<T, String> 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<T>(
showSelectedIcon: false,
segments: [
for (final e in options.entries)
ButtonSegment<T>(value: e.key, label: Text(e.value)),
],
selected: {value},
onSelectionChanged: (s) => onChanged(s.first),
),
],
),
);
}
}

View File

@ -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<AppColors> {
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<AppColors>() ?? _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)!,
);
}
}

View File

@ -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(

View File

@ -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';

View File

@ -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(),
);
}
}

View File

@ -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')),
),
);
}

View File

@ -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<int, int> 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<bool>());
}
});
});
}