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:
parent
ba79775fbc
commit
e4bcfb57a6
@ -65,9 +65,12 @@ class DatalogPlayer {
|
|||||||
_status = PlaybackStatus.idle;
|
_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) {
|
void seek(int index) {
|
||||||
|
if (_frames.isEmpty) return;
|
||||||
_position = index.clamp(0, _frames.length - 1);
|
_position = index.clamp(0, _frames.length - 1);
|
||||||
|
_controller.add(_frames[_position].data);
|
||||||
onProgress?.call(_position, _frames.length);
|
onProgress?.call(_position, _frames.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
|
import '../protocol/sensor_state.dart';
|
||||||
|
|
||||||
/// Definition of a boolean flag sensor shown as indicator pills.
|
/// Definition of a boolean flag sensor shown as indicator pills.
|
||||||
class FlagDef {
|
class FlagDef {
|
||||||
final String id;
|
final String id;
|
||||||
final String displayName;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ class SensorDef {
|
|||||||
final String unit;
|
final String unit;
|
||||||
final double min;
|
final double min;
|
||||||
final double max;
|
final double max;
|
||||||
|
/// Decimal places shown for this sensor in its base unit.
|
||||||
|
final int decimals;
|
||||||
|
|
||||||
const SensorDef({
|
const SensorDef({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -12,5 +14,6 @@ class SensorDef {
|
|||||||
required this.unit,
|
required this.unit,
|
||||||
required this.min,
|
required this.min,
|
||||||
required this.max,
|
required this.max,
|
||||||
|
this.decimals = 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,27 @@
|
|||||||
|
import '../protocol/sensor_state.dart';
|
||||||
import 'sensor_def.dart';
|
import 'sensor_def.dart';
|
||||||
import 'flag_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 = [
|
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(
|
SensorDef(
|
||||||
id: 'map',
|
id: 'rpm', displayName: 'RPM', unit: 'rpm', min: 0, max: 9000, decimals: 0),
|
||||||
displayName: 'Manifold pressure',
|
SensorDef(
|
||||||
unit: 'kPa',
|
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,
|
min: 0,
|
||||||
max: 300),
|
max: 20,
|
||||||
|
decimals: 2),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
id: 'ign', displayName: 'Timing advance', unit: '°', min: -20, max: 60),
|
||||||
SensorDef(
|
|
||||||
id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20),
|
|
||||||
SensorDef(
|
|
||||||
id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60),
|
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
@ -28,41 +33,99 @@ const List<SensorDef> sensorDefs = [
|
|||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
||||||
// O2/AFR/AIN scales not yet bench-verified: shown as raw values.
|
// 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(
|
SensorDef(
|
||||||
id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120),
|
id: 'o2', displayName: 'O2 sensor', unit: 'raw', min: 0, max: 255, decimals: 0),
|
||||||
SensorDef(id: 'afr', displayName: 'AFR', unit: 'raw', min: 0, max: 255),
|
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(
|
SensorDef(
|
||||||
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
||||||
SensorDef(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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.
|
SensorDef sensorDef(String id) =>
|
||||||
const List<FlagDef> flagDefs = [
|
sensorDefs.firstWhere((d) => d.id == id, orElse: () => sensorDefs.first);
|
||||||
FlagDef(id: 'mil', displayName: 'MIL'),
|
|
||||||
FlagDef(id: 'fuelpump', displayName: 'Fuel Pump'),
|
/// Base-unit value of sensor [id] in [s].
|
||||||
FlagDef(id: 'fanout', displayName: 'FAN Out'),
|
double sensorValue(SensorState s, String id) {
|
||||||
FlagDef(id: 'vtec', displayName: 'VTEC'),
|
switch (id) {
|
||||||
FlagDef(id: 'knock', displayName: 'Knock'),
|
case 'rpm': return s.rpm;
|
||||||
FlagDef(id: 'revlimit', displayName: 'Rev Limit'),
|
case 'vss': return s.vss;
|
||||||
FlagDef(id: 'launch', displayName: 'Launch'),
|
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
101
lib/core/models/units.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -238,6 +238,30 @@ const Map<String, String> kproDtcMap = {
|
|||||||
'ERR17_bit7': 'ERR17.7 — Undocumented code',
|
'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
|
/// Returns a list of active DTC descriptions from the given raw error bytes
|
||||||
/// using the provided map.
|
/// using the provided map.
|
||||||
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||||||
import 'sensor_state.dart';
|
import 'sensor_state.dart';
|
||||||
import 'temp_table.dart';
|
import 'temp_table.dart';
|
||||||
import 'neg8.dart';
|
import 'neg8.dart';
|
||||||
|
import 'dtc_map.dart';
|
||||||
|
|
||||||
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
||||||
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
||||||
@ -140,6 +141,9 @@ SensorState parseKPro(Uint8List frame) {
|
|||||||
knock: knock,
|
knock: knock,
|
||||||
revLimit: revLimit,
|
revLimit: revLimit,
|
||||||
launch: launch,
|
launch: launch,
|
||||||
|
iatError: anyDtcBit(errBytes, kproIatErrorBits),
|
||||||
|
ectError: anyDtcBit(errBytes, kproEctErrorBits),
|
||||||
|
o2Error: anyDtcBit(errBytes, kproO2ErrorBits),
|
||||||
errBytes: errBytes,
|
errBytes: errBytes,
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||||||
import 'sensor_state.dart';
|
import 'sensor_state.dart';
|
||||||
import 'temp_table.dart';
|
import 'temp_table.dart';
|
||||||
import 'neg8.dart';
|
import 'neg8.dart';
|
||||||
|
import 'dtc_map.dart';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// S300 datalog frame (128 bytes, response to request 1B 00 E5).
|
// 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
|
final bool knock = frame[13] > 0; // KRtrd
|
||||||
// SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3)
|
// SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3)
|
||||||
final bool launch = (sw3 & 0x80) != 0;
|
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(
|
return SensorState(
|
||||||
rpm: rpm,
|
rpm: rpm,
|
||||||
@ -179,6 +188,17 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
|||||||
knock: knock,
|
knock: knock,
|
||||||
revLimit: revLimit,
|
revLimit: revLimit,
|
||||||
launch: launch,
|
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,
|
errBytes: errBytes,
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -33,6 +33,19 @@ class SensorState {
|
|||||||
final bool knock;
|
final bool knock;
|
||||||
final bool revLimit;
|
final bool revLimit;
|
||||||
final bool launch;
|
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)
|
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
||||||
final List<int> errBytes;
|
final List<int> errBytes;
|
||||||
// Frame timestamp
|
// Frame timestamp
|
||||||
@ -70,6 +83,17 @@ class SensorState {
|
|||||||
required this.knock,
|
required this.knock,
|
||||||
required this.revLimit,
|
required this.revLimit,
|
||||||
required this.launch,
|
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.errBytes,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
});
|
});
|
||||||
@ -177,6 +201,17 @@ class SensorState {
|
|||||||
knock: knock ?? this.knock,
|
knock: knock ?? this.knock,
|
||||||
revLimit: revLimit ?? this.revLimit,
|
revLimit: revLimit ?? this.revLimit,
|
||||||
launch: launch ?? this.launch,
|
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,
|
errBytes: errBytes ?? this.errBytes,
|
||||||
timestamp: timestamp ?? this.timestamp,
|
timestamp: timestamp ?? this.timestamp,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import '../datalog/datalog_db.dart';
|
|||||||
import '../datalog/datalog_player.dart';
|
import '../datalog/datalog_player.dart';
|
||||||
import '../datalog/datalog_recorder.dart';
|
import '../datalog/datalog_recorder.dart';
|
||||||
import '../models/datalog_session.dart';
|
import '../models/datalog_session.dart';
|
||||||
|
import '../bluetooth/bt_poller.dart';
|
||||||
import 'bt_provider.dart';
|
import 'bt_provider.dart';
|
||||||
|
import 'sensor_history_provider.dart';
|
||||||
import 'settings_provider.dart';
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
// ─── Singletons ───────────────────────────────────────────────────────────────
|
// ─── Singletons ───────────────────────────────────────────────────────────────
|
||||||
@ -114,6 +116,8 @@ final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
|||||||
class PlaybackState {
|
class PlaybackState {
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final int? sessionId;
|
final int? sessionId;
|
||||||
|
/// ECU type the session was recorded with; frames are parsed with it.
|
||||||
|
final EcuType ecuType;
|
||||||
final int position;
|
final int position;
|
||||||
final int total;
|
final int total;
|
||||||
final double speed;
|
final double speed;
|
||||||
@ -122,6 +126,7 @@ class PlaybackState {
|
|||||||
const PlaybackState({
|
const PlaybackState({
|
||||||
this.isActive = false,
|
this.isActive = false,
|
||||||
this.sessionId,
|
this.sessionId,
|
||||||
|
this.ecuType = EcuType.s300,
|
||||||
this.position = 0,
|
this.position = 0,
|
||||||
this.total = 0,
|
this.total = 0,
|
||||||
this.speed = 1.0,
|
this.speed = 1.0,
|
||||||
@ -141,6 +146,7 @@ class PlaybackState {
|
|||||||
PlaybackState(
|
PlaybackState(
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
sessionId: sessionId ?? this.sessionId,
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
ecuType: ecuType,
|
||||||
position: position ?? this.position,
|
position: position ?? this.position,
|
||||||
total: total ?? this.total,
|
total: total ?? this.total,
|
||||||
speed: speed ?? this.speed,
|
speed: speed ?? this.speed,
|
||||||
@ -153,9 +159,12 @@ class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
|||||||
|
|
||||||
PlaybackNotifier(this._ref) : super(const 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);
|
final player = _ref.read(datalogPlayerProvider);
|
||||||
await player.load(sessionId);
|
await player.load(sessionId);
|
||||||
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||||
|
|
||||||
player.onProgress = (pos, total) {
|
player.onProgress = (pos, total) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@ -168,6 +177,7 @@ class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
|||||||
state = PlaybackState(
|
state = PlaybackState(
|
||||||
isActive: true,
|
isActive: true,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
ecuType: ecuType == EcuType.kpro.name ? EcuType.kpro : EcuType.s300,
|
||||||
total: player.totalFrames,
|
total: player.totalFrames,
|
||||||
speed: player.speed,
|
speed: player.speed,
|
||||||
status: PlaybackStatus.idle,
|
status: PlaybackStatus.idle,
|
||||||
@ -187,6 +197,7 @@ class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
|||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _ref.read(datalogPlayerProvider).stop();
|
await _ref.read(datalogPlayerProvider).stop();
|
||||||
state = const PlaybackState();
|
state = const PlaybackState();
|
||||||
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void seek(int index) {
|
void seek(int index) {
|
||||||
@ -204,3 +215,11 @@ final playbackProvider =
|
|||||||
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
||||||
(ref) => PlaybackNotifier(ref),
|
(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));
|
||||||
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import '../protocol/kpro_parser.dart';
|
|||||||
import '../protocol/s300_parser.dart';
|
import '../protocol/s300_parser.dart';
|
||||||
import '../protocol/sensor_state.dart';
|
import '../protocol/sensor_state.dart';
|
||||||
import 'bt_provider.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
|
// 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
|
// still pass validation — rare, but enough to occasionally show an
|
||||||
@ -52,16 +52,19 @@ class DecodeStats {
|
|||||||
|
|
||||||
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
||||||
|
|
||||||
/// Emits a parsed [SensorState] for every live data frame received from the ECU.
|
/// Emits a parsed [SensorState] for every live data frame received from the
|
||||||
/// Automatically picks S300 or KPro parser based on [settingsProvider].
|
/// 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 sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
||||||
final ecuType = ref.watch(settingsProvider.select((s) => s.ecuType));
|
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
||||||
final btNotifier = ref.watch(btProvider.notifier);
|
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
|
// `await for` treats an error event as fatal; absorb errors upstream so the
|
||||||
// loop keeps waiting for the next frame.
|
// loop keeps waiting for the next frame.
|
||||||
final safeStream =
|
final safeStream = source.handleError((Object _, StackTrace __) {});
|
||||||
btNotifier.frameStream.handleError((Object _, StackTrace __) {});
|
|
||||||
|
|
||||||
// Adapter default frames are already filtered out in BtNotifier, so every
|
// 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:
|
// frame here is a real ECU reading. No "hold last value" logic any more:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../bluetooth/bt_poller.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
|
import '../models/units.dart';
|
||||||
|
|
||||||
/// Polling choices offered in Settings. Never faster than 10 Hz: the adapter
|
/// 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
|
/// 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>(
|
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
(ref) => SettingsNotifier(),
|
(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(),
|
||||||
|
);
|
||||||
|
|||||||
@ -1,12 +1,35 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
||||||
|
|
||||||
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
const _themeKey = 'themeVariant';
|
||||||
ThemeNotifier() : super(AppThemeVariant.redDark);
|
|
||||||
|
|
||||||
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 =
|
final themeProvider =
|
||||||
|
|||||||
@ -56,6 +56,13 @@ class HvbtApp extends ConsumerWidget {
|
|||||||
title: 'HV BT Dashboard',
|
title: 'HV BT Dashboard',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.build(themeVariant),
|
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(),
|
home: const BtPickerScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,112 +1,81 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import 'widgets/analog_gauge.dart';
|
import 'dashboard_slots_provider.dart';
|
||||||
|
import 'widgets/dashboard_common.dart';
|
||||||
import 'widgets/flag_pill.dart';
|
import 'widgets/flag_pill.dart';
|
||||||
import 'widgets/sensor_tile.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 {
|
class DashboardHorizontal extends ConsumerWidget {
|
||||||
const DashboardHorizontal({super.key});
|
const DashboardHorizontal({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final themeVariant = ref.watch(themeProvider);
|
final config = ref.watch(dashboardConfigProvider);
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
final units = ref.watch(unitPrefsProvider);
|
||||||
final slots = ref.watch(dashboardSlotsProvider);
|
|
||||||
|
|
||||||
return Container(
|
Widget column(int from) => SizedBox(
|
||||||
color: const Color(0xFF0A0A0A),
|
width: 190,
|
||||||
padding: const EdgeInsets.all(8),
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
for (int i = from; i < from + 5; i++)
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Left tiles (slots 0-2)
|
|
||||||
SizedBox(
|
|
||||||
width: 140,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
for (int i = 0; i < 3; i++)
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
|
||||||
child: SensorTile(
|
|
||||||
slotIndex: i,
|
|
||||||
sensorId: slots[i],
|
|
||||||
state: state,
|
|
||||||
accentColor: colors.accent,
|
|
||||||
onTap: () => _pickSensor(context, ref, i),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
// Center gauge
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Padding(
|
||||||
child: AnalogGauge(
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
value: state.rpm,
|
child: SensorTile(
|
||||||
minValue: 0,
|
sensorId: config.values[i],
|
||||||
maxValue: 9000,
|
state: state,
|
||||||
label: 'RPM',
|
units: units,
|
||||||
unit: 'rpm',
|
onLongPress: () => editDashboardValue(context, ref, i),
|
||||||
accentColor: colors.accent,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
],
|
||||||
// Right tiles (slots 3-5)
|
|
||||||
SizedBox(
|
|
||||||
width: 140,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
for (int i = 3; i < 6; i++)
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
|
||||||
child: SensorTile(
|
|
||||||
slotIndex: i,
|
|
||||||
sensorId: slots[i],
|
|
||||||
state: state,
|
|
||||||
accentColor: colors.accent,
|
|
||||||
onTap: () => _pickSensor(context, ref, i),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Flag pills row
|
);
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: FlagRow(state: state, accentColor: colors.accent),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
return Container(
|
||||||
showModalBottomSheet<void>(
|
color: c.background,
|
||||||
context: context,
|
// SafeArea: in landscape the system navigation bar sits on a side.
|
||||||
builder: (_) => SensorPickerSheet(
|
child: SafeArea(
|
||||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
top: false,
|
||||||
onSelected: (id) {
|
bottom: false,
|
||||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
child: Padding(
|
||||||
Navigator.pop(context);
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,123 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
// Default sensor IDs for the 6 dashboard tile slots
|
import '../../../core/models/sensor_defs.dart';
|
||||||
const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat'];
|
|
||||||
|
|
||||||
class DashboardSlotsNotifier extends StateNotifier<List<String>> {
|
/// What the dashboard shows, per the requirement document: a selectable
|
||||||
DashboardSlotsNotifier() : super(List.from(_defaultSlots));
|
/// 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 String main;
|
||||||
final next = List<String>.from(state);
|
final String sub;
|
||||||
next[index] = sensorId;
|
final List<String> values;
|
||||||
state = next;
|
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 =
|
final dashboardConfigProvider =
|
||||||
StateNotifierProvider<DashboardSlotsNotifier, List<String>>(
|
StateNotifierProvider<DashboardConfigNotifier, DashboardConfig>(
|
||||||
(ref) => DashboardSlotsNotifier(),
|
(ref) => DashboardConfigNotifier(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,81 +1,94 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import 'widgets/analog_gauge.dart';
|
import 'dashboard_slots_provider.dart';
|
||||||
|
import 'widgets/dashboard_common.dart';
|
||||||
import 'widgets/flag_pill.dart';
|
import 'widgets/flag_pill.dart';
|
||||||
import 'widgets/sensor_tile.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 {
|
class DashboardVertical extends ConsumerWidget {
|
||||||
const DashboardVertical({super.key});
|
const DashboardVertical({super.key});
|
||||||
|
|
||||||
|
static const _tileHeight = 64.0;
|
||||||
|
static const _gap = 6.0;
|
||||||
|
static const _flagsHeight = 30.0 + 2 * _gap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final themeVariant = ref.watch(themeProvider);
|
final config = ref.watch(dashboardConfigProvider);
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
final units = ref.watch(unitPrefsProvider);
|
||||||
final slots = ref.watch(dashboardSlotsProvider);
|
|
||||||
|
|
||||||
return Container(
|
final tiles = Padding(
|
||||||
color: const Color(0xFF0A0A0A),
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
child: SingleChildScrollView(
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
// Left column is values 0-4, right column 5-9, matching landscape.
|
||||||
|
for (int row = 0; row < 5; row++)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
padding: EdgeInsets.only(top: row == 0 ? 0 : _gap),
|
||||||
child: AnalogGauge(
|
child: SizedBox(
|
||||||
value: state.rpm,
|
height: _tileHeight,
|
||||||
minValue: 0,
|
child: Row(
|
||||||
maxValue: 9000,
|
children: [
|
||||||
label: 'RPM',
|
for (final i in [row, row + 5]) ...[
|
||||||
unit: 'rpm',
|
if (i != row) const SizedBox(width: _gap),
|
||||||
accentColor: colors.accent,
|
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) {
|
return Container(
|
||||||
showModalBottomSheet<void>(
|
color: c.background,
|
||||||
context: context,
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
builder: (_) => SensorPickerSheet(
|
const tilesHeight = 5 * _tileHeight + 4 * _gap + 8;
|
||||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
final gaugeSize = math.max(
|
||||||
onSelected: (id) {
|
140.0,
|
||||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
math.min(box.maxWidth - 32, box.maxHeight - tilesHeight - _flagsHeight),
|
||||||
Navigator.pop(context);
|
);
|
||||||
},
|
// 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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,14 @@ class AnalogGauge extends StatefulWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final String unit;
|
final String unit;
|
||||||
final Color accentColor;
|
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({
|
const AnalogGauge({
|
||||||
super.key,
|
super.key,
|
||||||
@ -17,6 +25,12 @@ class AnalogGauge extends StatefulWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.unit,
|
required this.unit,
|
||||||
required this.accentColor,
|
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
|
@override
|
||||||
@ -74,9 +88,14 @@ class _AnalogGaugeState extends State<AnalogGauge>
|
|||||||
painter: _GaugePainter(
|
painter: _GaugePainter(
|
||||||
normalizedValue: _anim.value,
|
normalizedValue: _anim.value,
|
||||||
accentColor: widget.accentColor,
|
accentColor: widget.accentColor,
|
||||||
displayValue: widget.value,
|
valueText: widget.valueText ?? widget.value.toStringAsFixed(0),
|
||||||
|
subText: widget.subText,
|
||||||
unit: widget.unit,
|
unit: widget.unit,
|
||||||
label: widget.label,
|
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 {
|
class _GaugePainter extends CustomPainter {
|
||||||
final double normalizedValue; // 0.0–1.0
|
final double normalizedValue; // 0.0–1.0
|
||||||
final Color accentColor;
|
final Color accentColor;
|
||||||
final double displayValue;
|
final String valueText;
|
||||||
|
final String? subText;
|
||||||
final String unit;
|
final String unit;
|
||||||
final String label;
|
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 _startAngle = 135 * pi / 180; // 7:30 position
|
||||||
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
||||||
@ -97,9 +121,14 @@ class _GaugePainter extends CustomPainter {
|
|||||||
_GaugePainter({
|
_GaugePainter({
|
||||||
required this.normalizedValue,
|
required this.normalizedValue,
|
||||||
required this.accentColor,
|
required this.accentColor,
|
||||||
required this.displayValue,
|
required this.valueText,
|
||||||
|
required this.subText,
|
||||||
required this.unit,
|
required this.unit,
|
||||||
required this.label,
|
required this.label,
|
||||||
|
required this.foreground,
|
||||||
|
required this.muted,
|
||||||
|
required this.track,
|
||||||
|
required this.background,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -110,7 +139,7 @@ class _GaugePainter extends CustomPainter {
|
|||||||
|
|
||||||
// ── Background arc ──────────────────────────────────────────────
|
// ── Background arc ──────────────────────────────────────────────
|
||||||
final bgPaint = Paint()
|
final bgPaint = Paint()
|
||||||
..color = const Color(0xFF2A2A2A)
|
..color = track
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = size.width * 0.06
|
..strokeWidth = size.width * 0.06
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
@ -159,11 +188,11 @@ class _GaugePainter extends CustomPainter {
|
|||||||
|
|
||||||
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
||||||
final majorPaint = Paint()
|
final majorPaint = Paint()
|
||||||
..color = Colors.white54
|
..color = muted
|
||||||
..strokeWidth = size.width * 0.012
|
..strokeWidth = size.width * 0.012
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
final minorPaint = Paint()
|
final minorPaint = Paint()
|
||||||
..color = Colors.white24
|
..color = muted.withAlpha(90)
|
||||||
..strokeWidth = size.width * 0.006
|
..strokeWidth = size.width * 0.006
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
@ -189,7 +218,7 @@ class _GaugePainter extends CustomPainter {
|
|||||||
final tailR = size.width * 0.08;
|
final tailR = size.width * 0.08;
|
||||||
|
|
||||||
final needlePaint = Paint()
|
final needlePaint = Paint()
|
||||||
..color = Colors.white
|
..color = foreground
|
||||||
..strokeWidth = size.width * 0.018
|
..strokeWidth = size.width * 0.018
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
@ -203,21 +232,21 @@ class _GaugePainter extends CustomPainter {
|
|||||||
canvas.drawCircle(
|
canvas.drawCircle(
|
||||||
center,
|
center,
|
||||||
size.width * 0.04,
|
size.width * 0.04,
|
||||||
Paint()..color = Colors.white,
|
Paint()..color = foreground,
|
||||||
);
|
);
|
||||||
canvas.drawCircle(
|
canvas.drawCircle(
|
||||||
center,
|
center,
|
||||||
size.width * 0.025,
|
size.width * 0.025,
|
||||||
Paint()..color = const Color(0xFF0A0A0A),
|
Paint()..color = background,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
||||||
// Value
|
// Value
|
||||||
final valSpan = TextSpan(
|
final valSpan = TextSpan(
|
||||||
text: displayValue.toStringAsFixed(0),
|
text: valueText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: foreground,
|
||||||
fontSize: size.width * 0.14,
|
fontSize: size.width * 0.14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: -1,
|
letterSpacing: -1,
|
||||||
@ -240,7 +269,7 @@ class _GaugePainter extends CustomPainter {
|
|||||||
final unitSpan = TextSpan(
|
final unitSpan = TextSpan(
|
||||||
text: unit,
|
text: unit,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: muted,
|
||||||
fontSize: size.width * 0.07,
|
fontSize: size.width * 0.07,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -256,11 +285,59 @@ class _GaugePainter extends CustomPainter {
|
|||||||
size.width * 0.1 + valPainter.height + 2,
|
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
|
@override
|
||||||
bool shouldRepaint(_GaugePainter old) =>
|
bool shouldRepaint(_GaugePainter old) =>
|
||||||
old.normalizedValue != normalizedValue ||
|
old.normalizedValue != normalizedValue ||
|
||||||
old.accentColor != accentColor ||
|
old.accentColor != accentColor ||
|
||||||
old.displayValue != displayValue;
|
old.valueText != valueText ||
|
||||||
|
old.subText != subText ||
|
||||||
|
old.label != label ||
|
||||||
|
old.unit != unit ||
|
||||||
|
old.foreground != foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
95
lib/ui/screens/dashboard/widgets/dashboard_common.dart
Normal file
95
lib/ui/screens/dashboard/widgets/dashboard_common.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,31 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
import '../../../../core/protocol/sensor_state.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 {
|
class FlagPill extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
@ -15,51 +41,65 @@ class FlagPill extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 100),
|
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(
|
decoration: BoxDecoration(
|
||||||
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
|
color: active ? color.withAlpha(200) : c.surface,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: active ? color : Colors.white12,
|
color: active ? color : c.accent.withAlpha(90),
|
||||||
width: 1.5,
|
width: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: FittedBox(
|
||||||
label,
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(
|
child: Text(
|
||||||
color: active ? color : Colors.grey,
|
label,
|
||||||
fontSize: 11,
|
style: TextStyle(
|
||||||
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
color: active ? Colors.white : c.onSurfaceMuted,
|
||||||
letterSpacing: 1,
|
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 {
|
class FlagRow extends StatelessWidget {
|
||||||
final SensorState state;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Wrap(
|
return Row(
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 6,
|
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
FlagPill(label: 'MIL', active: state.mil, color: Colors.amber),
|
for (var i = 0; i < flagIds.length; i++) ...[
|
||||||
FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue),
|
if (i > 0) const SizedBox(width: 6),
|
||||||
FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red),
|
Expanded(
|
||||||
FlagPill(label: 'FUEL PUMP', active: state.fuelPump, color: Colors.cyan),
|
child: GestureDetector(
|
||||||
FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange),
|
onLongPress: () => onLongPress(i),
|
||||||
FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green),
|
child: FlagPill(
|
||||||
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),
|
label: flagDef(flagIds[i]).displayName,
|
||||||
|
active: flagDef(flagIds[i]).read(state),
|
||||||
|
color: flagColor(flagIds[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,77 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_defs.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 String currentId;
|
||||||
final void Function(String id) onSelected;
|
|
||||||
|
|
||||||
const SensorPickerSheet({
|
const _PickerSheet({
|
||||||
super.key,
|
required this.title,
|
||||||
|
required this.items,
|
||||||
required this.currentId,
|
required this.currentId,
|
||||||
required this.onSelected,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
expand: false,
|
expand: false,
|
||||||
initialChildSize: 0.6,
|
initialChildSize: 0.6,
|
||||||
@ -20,58 +79,61 @@ class SensorPickerSheet extends StatelessWidget {
|
|||||||
maxChildSize: 0.9,
|
maxChildSize: 0.9,
|
||||||
builder: (context, scrollController) {
|
builder: (context, scrollController) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: 40, height: 4,
|
width: 40,
|
||||||
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white24,
|
color: c.onSurfaceMuted.withAlpha(90),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'SELECT SENSOR',
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: c.onSurfaceMuted,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
Divider(height: 1, color: c.divider),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: sensorDefs.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final def = sensorDefs[i];
|
final item = items[i];
|
||||||
final isSelected = def.id == currentId;
|
final isSelected = item.id == currentId;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
def.displayName,
|
item.title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected ? Colors.white : Colors.white70,
|
color: c.onSurface,
|
||||||
fontWeight: isSelected
|
fontWeight:
|
||||||
? FontWeight.bold
|
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: item.subtitle == null
|
||||||
def.unit,
|
? null
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
: Text(
|
||||||
),
|
item.subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: c.onSurfaceMuted, fontSize: 12),
|
||||||
|
),
|
||||||
trailing: isSelected
|
trailing: isSelected
|
||||||
? const Icon(Icons.check, color: Colors.green, size: 18)
|
? Icon(Icons.check, color: c.accent, size: 18)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => onSelected(def.id),
|
onTap: () => Navigator.pop(context, item.id),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,128 +1,139 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_defs.dart';
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../../core/models/units.dart';
|
||||||
import '../../../../core/protocol/sensor_state.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 {
|
class SensorTile extends StatelessWidget {
|
||||||
final int slotIndex;
|
|
||||||
final String sensorId;
|
final String sensorId;
|
||||||
final SensorState state;
|
final SensorState state;
|
||||||
final Color accentColor;
|
final UnitPrefs units;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
const SensorTile({
|
const SensorTile({
|
||||||
super.key,
|
super.key,
|
||||||
required this.slotIndex,
|
|
||||||
required this.sensorId,
|
required this.sensorId,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.accentColor,
|
required this.units,
|
||||||
required this.onTap,
|
required this.onLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final def = sensorDefs.firstWhere(
|
final c = AppColors.of(context);
|
||||||
(d) => d.id == sensorId,
|
final view = sensorView(sensorId, units);
|
||||||
orElse: () => sensorDefs.first,
|
final base = sensorValue(state, sensorId);
|
||||||
);
|
final fraction = view.fraction(base);
|
||||||
final value = _getValue(state, sensorId);
|
final caption =
|
||||||
final normalized =
|
view.unit.isEmpty ? view.name : '${view.unit} (${view.name})';
|
||||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onLongPress: onTap,
|
onLongPress: onLongPress,
|
||||||
child: Container(
|
child: LayoutBuilder(
|
||||||
decoration: BoxDecoration(
|
builder: (context, box) {
|
||||||
color: const Color(0xFF1A1A1A),
|
final valueText = Text(
|
||||||
borderRadius: BorderRadius.circular(10),
|
view.format(base),
|
||||||
border: Border.all(color: Colors.white10),
|
style: TextStyle(
|
||||||
),
|
color: fraction > 0.9 ? Colors.red : c.onSurface,
|
||||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
fontSize: 26,
|
||||||
child: Column(
|
fontWeight: FontWeight.bold,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fontStyle: FontStyle.italic,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
letterSpacing: -0.5,
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
def.displayName.toUpperCase(),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontSize: 10,
|
|
||||||
letterSpacing: 1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_format(value, sensorId),
|
caption,
|
||||||
style: const TextStyle(
|
maxLines: 1,
|
||||||
color: Colors.white,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontSize: 22,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
color: c.onSurfaceMuted,
|
||||||
letterSpacing: -0.5,
|
fontSize: 10,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 3),
|
Expanded(
|
||||||
Padding(
|
child: FittedBox(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
alignment: Alignment.centerRight,
|
||||||
def.unit,
|
child: Text(
|
||||||
style: TextStyle(color: accentColor, fontSize: 11),
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ class DatalogListScreen extends ConsumerWidget {
|
|||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
itemCount: sessions.length,
|
itemCount: sessions.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
itemBuilder: (context, i) =>
|
itemBuilder: (context, i) =>
|
||||||
_SessionTile(session: sessions[i]),
|
_SessionTile(session: sessions[i]),
|
||||||
),
|
),
|
||||||
@ -115,8 +115,11 @@ class _SessionTile extends ConsumerWidget {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
await ref
|
await ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackProvider.notifier)
|
||||||
.loadSession(session.id!);
|
.loadSession(session.id!, session.ecuType);
|
||||||
ref.read(playbackProvider.notifier).play();
|
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);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/datalog/datalog_player.dart';
|
import '../../../core/datalog/datalog_player.dart';
|
||||||
import '../../../core/providers/datalog_provider.dart';
|
import '../../../core/providers/datalog_provider.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
class DatalogPlaybackBar extends ConsumerWidget {
|
class DatalogPlaybackBar extends ConsumerWidget {
|
||||||
final PlaybackState state;
|
final PlaybackState state;
|
||||||
@ -11,9 +12,10 @@ class DatalogPlaybackBar extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final notifier = ref.read(playbackProvider.notifier);
|
final notifier = ref.read(playbackProvider.notifier);
|
||||||
final isPlaying = state.status == PlaybackStatus.playing;
|
final isPlaying = state.status == PlaybackStatus.playing;
|
||||||
|
final c = AppColors.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -41,7 +43,7 @@ class DatalogPlaybackBar extends ConsumerWidget {
|
|||||||
Text(
|
Text(
|
||||||
'${state.position} / ${state.total}',
|
'${state.position} / ${state.total}',
|
||||||
style:
|
style:
|
||||||
const TextStyle(color: Colors.grey, fontSize: 11),
|
TextStyle(color: c.onSurfaceMuted, fontSize: 11),
|
||||||
),
|
),
|
||||||
// Play / Pause / Stop
|
// Play / Pause / Stop
|
||||||
Row(
|
Row(
|
||||||
@ -69,8 +71,7 @@ class DatalogPlaybackBar extends ConsumerWidget {
|
|||||||
value: state.speed,
|
value: state.speed,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
underline: const SizedBox(),
|
underline: const SizedBox(),
|
||||||
style: const TextStyle(
|
style: TextStyle(color: c.onSurface, fontSize: 12),
|
||||||
color: Colors.white70, fontSize: 12),
|
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
||||||
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../core/protocol/dtc_map.dart';
|
import '../../../core/protocol/dtc_map.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/sensor_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 '../../../core/bluetooth/bt_poller.dart';
|
||||||
import 'widgets/dtc_row.dart';
|
import 'widgets/dtc_row.dart';
|
||||||
|
|
||||||
@ -138,7 +139,8 @@ class _DtcScreenState extends ConsumerState<DtcScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final bt = ref.watch(btProvider);
|
final bt = ref.watch(btProvider);
|
||||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
||||||
|
final c = AppColors.of(context);
|
||||||
|
|
||||||
final dtcMap =
|
final dtcMap =
|
||||||
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
||||||
@ -148,7 +150,7 @@ class _DtcScreenState extends ConsumerState<DtcScreen> {
|
|||||||
children: [
|
children: [
|
||||||
// Header bar
|
// Header bar
|
||||||
Container(
|
Container(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -175,12 +177,12 @@ class _DtcScreenState extends ConsumerState<DtcScreen> {
|
|||||||
Text(
|
Text(
|
||||||
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
||||||
style:
|
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
|
// DTC list
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -201,7 +203,7 @@ class _DtcScreenState extends ConsumerState<DtcScreen> {
|
|||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
itemCount: active.length,
|
itemCount: active.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
const Divider(height: 1, color: Colors.white10),
|
Divider(height: 1, color: c.divider),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
||||||
final parts = full.split(' — ');
|
final parts = full.split(' — ');
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
class DtcRow extends StatelessWidget {
|
class DtcRow extends StatelessWidget {
|
||||||
final String code;
|
final String code;
|
||||||
@ -34,7 +35,8 @@ class DtcRow extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
description,
|
description,
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
style: TextStyle(
|
||||||
|
color: AppColors.of(context).onSurface, fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/sensor_history_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../../ui/theme/app_colors.dart';
|
||||||
|
import '../dashboard/widgets/sensor_picker_sheet.dart';
|
||||||
import 'widgets/graph_time_selector.dart';
|
import 'widgets/graph_time_selector.dart';
|
||||||
import 'widgets/sensor_graph_panel.dart';
|
import 'widgets/sensor_graph_panel.dart';
|
||||||
|
|
||||||
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
||||||
final _graphSensorsProvider = StateProvider<List<String>>(
|
final _graphSensorsProvider = StateProvider<List<String>>(
|
||||||
(ref) => ['rpm', 'map', 'tps'],
|
(ref) => ['rpm', 'map', 'tps', 'ect'],
|
||||||
);
|
);
|
||||||
|
|
||||||
class GraphScreen extends ConsumerWidget {
|
class GraphScreen extends ConsumerWidget {
|
||||||
@ -18,7 +19,8 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final history = ref.watch(sensorHistoryProvider);
|
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 window = ref.watch(_graphWindowProvider);
|
||||||
final sensors = ref.watch(_graphSensorsProvider);
|
final sensors = ref.watch(_graphSensorsProvider);
|
||||||
|
|
||||||
@ -26,12 +28,12 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Time selector bar
|
// Time selector bar
|
||||||
Container(
|
Container(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: colors.surface,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('Window:',
|
Text('Window:',
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
style: TextStyle(color: colors.onSurfaceMuted, fontSize: 12)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GraphTimeSelector(
|
GraphTimeSelector(
|
||||||
windowSeconds: window,
|
windowSeconds: window,
|
||||||
@ -59,8 +61,8 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
Icon(Icons.show_chart,
|
Icon(Icons.show_chart,
|
||||||
size: 48, color: colors.accent.withAlpha(80)),
|
size: 48, color: colors.accent.withAlpha(80)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text('No sensors selected',
|
Text('No sensors selected',
|
||||||
style: TextStyle(color: Colors.grey)),
|
style: TextStyle(color: colors.onSurfaceMuted)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _addSensor(context, ref, sensors),
|
onPressed: () => _addSensor(context, ref, sensors),
|
||||||
@ -73,10 +75,9 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: sensors.length,
|
itemCount: sensors.length,
|
||||||
itemBuilder: (context, i) => SensorGraphPanel(
|
itemBuilder: (context, i) => SensorGraphPanel(
|
||||||
sensorId: sensors[i],
|
view: sensorView(sensors[i], units),
|
||||||
history: history,
|
history: history,
|
||||||
windowSeconds: window,
|
windowSeconds: window,
|
||||||
accentColor: colors.accent,
|
|
||||||
onRemove: () {
|
onRemove: () {
|
||||||
final next = List<String>.from(sensors)..removeAt(i);
|
final next = List<String>.from(sensors)..removeAt(i);
|
||||||
ref.read(_graphSensorsProvider.notifier).state = next;
|
ref.read(_graphSensorsProvider.notifier).state = next;
|
||||||
@ -88,49 +89,15 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addSensor(
|
Future<void> _addSensor(
|
||||||
BuildContext context, WidgetRef ref, List<String> current) {
|
BuildContext context, WidgetRef ref, List<String> current) async {
|
||||||
showModalBottomSheet<void>(
|
final id = await pickSensor(
|
||||||
context: context,
|
context,
|
||||||
backgroundColor: const Color(0xFF1A1A1A),
|
title: 'ADD SENSOR TO GRAPH',
|
||||||
shape: const RoundedRectangleBorder(
|
currentId: '',
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
units: ref.read(unitPrefsProvider),
|
||||||
),
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (id == null || current.contains(id)) return;
|
||||||
|
ref.read(_graphSensorsProvider.notifier).state = [...current, id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,13 +30,13 @@ class GraphTimeSelector extends StatelessWidget {
|
|||||||
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected ? accent : Colors.white24,
|
color: selected ? accent : Theme.of(context).dividerColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: selected ? accent : Colors.white54,
|
color: selected ? accent : Theme.of(context).hintColor,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,133 +1,203 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.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 '../../../../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 {
|
class SensorGraphPanel extends StatelessWidget {
|
||||||
final String sensorId;
|
final SensorView view;
|
||||||
final SensorHistory history;
|
final SensorHistory history;
|
||||||
final int windowSeconds;
|
final int windowSeconds;
|
||||||
final Color accentColor;
|
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const SensorGraphPanel({
|
const SensorGraphPanel({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sensorId,
|
required this.view,
|
||||||
required this.history,
|
required this.history,
|
||||||
required this.windowSeconds,
|
required this.windowSeconds,
|
||||||
required this.accentColor,
|
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final def = sensorDefs.firstWhere(
|
final c = AppColors.of(context);
|
||||||
(d) => d.id == sensorId,
|
final raw = history.get(view.def.id);
|
||||||
orElse: () => sensorDefs.first,
|
final now = raw.isEmpty ? 0.0 : raw.last.x;
|
||||||
);
|
// x = seconds before the latest sample (0 at the right edge).
|
||||||
final allSpots = history.get(sensorId);
|
final spots = [
|
||||||
|
for (final s in raw)
|
||||||
|
if (now - s.x <= windowSeconds) FlSpot(s.x - now, view.convert(s.y)),
|
||||||
|
];
|
||||||
|
|
||||||
// Slice to window
|
double? lo, hi;
|
||||||
final spots = _window(allSpots, windowSeconds.toDouble());
|
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(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
height: 170,
|
||||||
padding: const EdgeInsets.fromLTRB(8, 10, 12, 8),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 6, 4, 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: c.divider),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Stack(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// Header
|
Padding(
|
||||||
Row(
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
def.displayName.toUpperCase(),
|
|
||||||
style: TextStyle(
|
|
||||||
color: accentColor,
|
|
||||||
fontSize: 11,
|
|
||||||
letterSpacing: 1.5,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
def.unit,
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (spots.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
_fmt(spots.last.y),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: onRemove,
|
|
||||||
child: const Icon(Icons.close,
|
|
||||||
size: 16, color: Colors.white38),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Chart
|
|
||||||
SizedBox(
|
|
||||||
height: 90,
|
|
||||||
child: spots.length < 2
|
child: spots.length < 2
|
||||||
? const Center(
|
? Center(child: Text('Waiting for data…', style: muted))
|
||||||
child: Text('Waiting for data…',
|
|
||||||
style: TextStyle(color: Colors.white30,
|
|
||||||
fontSize: 12)))
|
|
||||||
: LineChart(
|
: LineChart(
|
||||||
LineChartData(
|
LineChartData(
|
||||||
|
minX: -windowSeconds.toDouble(),
|
||||||
|
maxX: 0,
|
||||||
|
minY: minY,
|
||||||
|
maxY: maxY,
|
||||||
|
clipData: const FlClipData.all(),
|
||||||
gridData: FlGridData(
|
gridData: FlGridData(
|
||||||
show: true,
|
show: true,
|
||||||
drawVerticalLine: false,
|
getDrawingHorizontalLine: (_) =>
|
||||||
getDrawingHorizontalLine: (_) => FlLine(
|
FlLine(color: c.divider, strokeWidth: 1),
|
||||||
color: Colors.white10,
|
getDrawingVerticalLine: (_) =>
|
||||||
strokeWidth: 1,
|
FlLine(color: c.divider, strokeWidth: 1),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
titlesData: const FlTitlesData(show: false),
|
titlesData: FlTitlesData(
|
||||||
minY: def.min.toDouble(),
|
topTitles: const AxisTitles(),
|
||||||
maxY: def.max.toDouble(),
|
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: [
|
lineBarsData: [
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
spots: spots,
|
spots: spots,
|
||||||
isCurved: true,
|
isCurved: false,
|
||||||
curveSmoothness: 0.2,
|
color: c.accent,
|
||||||
color: accentColor,
|
|
||||||
barWidth: 1.5,
|
barWidth: 1.5,
|
||||||
dotData: const FlDotData(show: false),
|
dotData: const FlDotData(show: false),
|
||||||
belowBarData: BarAreaData(
|
|
||||||
show: true,
|
|
||||||
color: accentColor.withAlpha(25),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
duration: Duration.zero,
|
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) {
|
static String _compact(double v) {
|
||||||
if (spots.isEmpty) return spots;
|
if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}k';
|
||||||
final maxX = spots.last.x;
|
if (v.abs() >= 10 || v == v.roundToDouble()) return v.toStringAsFixed(0);
|
||||||
final minX = maxX - windowSec;
|
return v.toStringAsFixed(1);
|
||||||
return spots.where((s) => s.x >= minX).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmt(double v) =>
|
|
||||||
v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,37 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/models/sensor_defs.dart';
|
import '../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
|
||||||
import 'widgets/sensor_row.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 {
|
class SensorListScreen extends ConsumerWidget {
|
||||||
const SensorListScreen({super.key});
|
const SensorListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final state = ref.watch(latestSensorProvider);
|
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,
|
itemCount: sensorDefs.length,
|
||||||
separatorBuilder: (_, __) =>
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
const Divider(height: 1, color: Colors.white10),
|
crossAxisCount: columns,
|
||||||
|
mainAxisExtent: 72,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final def = sensorDefs[i];
|
final id = sensorDefs[i].id;
|
||||||
return SensorRow(
|
return SensorRow(
|
||||||
def: def,
|
view: sensorView(id, units),
|
||||||
value: sensorValue(state, def.id),
|
baseValue: sensorValue(state, id),
|
||||||
accentColor: colors.accent,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,130 +1,129 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_def.dart';
|
import '../../../../core/models/units.dart';
|
||||||
import '../../../../core/protocol/sensor_state.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 {
|
class SensorRow extends StatelessWidget {
|
||||||
final SensorDef def;
|
final SensorView view;
|
||||||
final double value;
|
final double baseValue;
|
||||||
final Color accentColor;
|
|
||||||
|
|
||||||
const SensorRow({
|
const SensorRow({super.key, required this.view, required this.baseValue});
|
||||||
super.key,
|
|
||||||
required this.def,
|
|
||||||
required this.value,
|
|
||||||
required this.accentColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final normalized =
|
final c = AppColors.of(context);
|
||||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
final fraction = view.fraction(baseValue);
|
||||||
final isHigh = normalized > 0.8;
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: c.divider)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Label
|
Row(
|
||||||
SizedBox(
|
children: [
|
||||||
width: 52,
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
def.displayName,
|
view.name,
|
||||||
style: const TextStyle(
|
maxLines: 1,
|
||||||
color: Colors.grey,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
letterSpacing: 0.5,
|
color: c.accent,
|
||||||
),
|
fontSize: 14,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(view.unit,
|
||||||
|
style: TextStyle(color: c.onSurfaceMuted, fontSize: 13)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(height: 2),
|
||||||
// Value + unit
|
Row(
|
||||||
SizedBox(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
width: 80,
|
children: [
|
||||||
child: RichText(
|
Expanded(
|
||||||
textAlign: TextAlign.right,
|
child: Column(
|
||||||
text: TextSpan(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
Row(
|
||||||
text: _fmt(value),
|
children: [
|
||||||
style: const TextStyle(
|
Text(fmt(view.min), style: rangeStyle),
|
||||||
color: Colors.white,
|
const Spacer(),
|
||||||
fontSize: 14,
|
Text(fmt(view.max), style: rangeStyle),
|
||||||
fontWeight: FontWeight.w600,
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
TextSpan(
|
_SegmentBar(
|
||||||
text: ' ${def.unit}',
|
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(
|
style: TextStyle(
|
||||||
color: accentColor,
|
color: isHigh ? Colors.red : c.onSurface,
|
||||||
fontSize: 11,
|
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) {
|
/// Bar made of thin segments, like the reference screenshot.
|
||||||
switch (id) {
|
class _SegmentBar extends StatelessWidget {
|
||||||
case 'rpm': return s.rpm;
|
final double fraction;
|
||||||
case 'vss': return s.vss;
|
final Color color;
|
||||||
case 'map': return s.map;
|
final Color track;
|
||||||
case 'tps': return s.tps;
|
|
||||||
case 'inj': return s.inj;
|
const _SegmentBar({
|
||||||
case 'ign': return s.ign;
|
required this.fraction,
|
||||||
case 'ect': return s.ect;
|
required this.color,
|
||||||
case 'iat': return s.iat;
|
required this.track,
|
||||||
case 'bat': return s.bat;
|
});
|
||||||
case 'o2': return s.o2;
|
|
||||||
case 'gear': return s.gear.toDouble();
|
@override
|
||||||
case 'eth': return s.eth;
|
Widget build(BuildContext context) {
|
||||||
case 'pa': return s.pa;
|
return SizedBox(
|
||||||
case 'afr': return s.afr;
|
height: 14,
|
||||||
case 'strim': return s.strim;
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
case 'ltrim': return s.ltrim;
|
const segWidth = 3.0;
|
||||||
case 'ain0': return s.ain0;
|
const gap = 2.0;
|
||||||
case 'ain1': return s.ain1;
|
final count = (box.maxWidth / (segWidth + gap)).floor().clamp(1, 400);
|
||||||
case 'ain2': return s.ain2;
|
final lit = (fraction * count).round();
|
||||||
case 'ain3': return s.ain3;
|
return Row(
|
||||||
case 'ain4': return s.ain4;
|
children: [
|
||||||
case 'ain5': return s.ain5;
|
for (var i = 0; i < count; i++)
|
||||||
case 'ain6': return s.ain6;
|
Container(
|
||||||
case 'ain7': return s.ain7;
|
width: segWidth,
|
||||||
default: return 0;
|
margin: const EdgeInsets.only(right: gap),
|
||||||
|
color: i < lit ? color : track,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
import '../../../core/bluetooth/bt_poller.dart';
|
||||||
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/theme_provider.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import '../bt_picker/bt_picker_screen.dart';
|
import '../bt_picker/bt_picker_screen.dart';
|
||||||
|
import '../debug/raw_frame_debug_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@ -15,7 +17,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final themeVariant = ref.watch(themeProvider);
|
final themeVariant = ref.watch(themeProvider);
|
||||||
final bt = ref.watch(btProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
@ -48,7 +52,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
|
|
||||||
// ── ECU Protocol ─────────────────────────────────────────
|
// ── ECU Protocol ─────────────────────────────────────────
|
||||||
_SectionHeader('ECU PROTOCOL'),
|
_SectionHeader('ECU PROTOCOL'),
|
||||||
@ -74,7 +78,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
|
|
||||||
// ── Polling Interval ─────────────────────────────────────
|
// ── Polling Interval ─────────────────────────────────────
|
||||||
_SectionHeader('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 ────────────────────────────────────────────────
|
// ── Theme ────────────────────────────────────────────────
|
||||||
_SectionHeader('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 ─────────────────────────────────────────────
|
// ── App info ─────────────────────────────────────────────
|
||||||
_SectionHeader('ABOUT'),
|
_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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/providers/theme_provider.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 accent;
|
||||||
final Color background;
|
final Color background;
|
||||||
final Color surface;
|
final Color surface;
|
||||||
final Color surfaceVariant;
|
final Color surfaceVariant;
|
||||||
final Color onSurface;
|
final Color onSurface;
|
||||||
final Color onSurfaceMuted;
|
final Color onSurfaceMuted;
|
||||||
|
final Color divider;
|
||||||
|
final Color track; // empty part of bars and gauge arcs
|
||||||
|
|
||||||
const AppColors._({
|
const AppColors._({
|
||||||
required this.accent,
|
required this.accent,
|
||||||
@ -16,43 +20,36 @@ class AppColors {
|
|||||||
required this.surfaceVariant,
|
required this.surfaceVariant,
|
||||||
required this.onSurface,
|
required this.onSurface,
|
||||||
required this.onSurfaceMuted,
|
required this.onSurfaceMuted,
|
||||||
|
required this.divider,
|
||||||
|
required this.track,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const redDark = AppColors._(
|
static const _dark = AppColors._(
|
||||||
accent: Color(0xFFFF4444),
|
accent: Color(0xFFFF4444),
|
||||||
background: Color(0xFF0A0A0A),
|
background: Color(0xFF0A0A0A),
|
||||||
surface: Color(0xFF1A1A1A),
|
surface: Color(0xFF1A1A1A),
|
||||||
surfaceVariant: Color(0xFF242424),
|
surfaceVariant: Color(0xFF242424),
|
||||||
onSurface: Colors.white,
|
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),
|
accent: Color(0xFFCC0000),
|
||||||
background: Color(0xFFF2F2F2),
|
background: Color(0xFFF2F2F2),
|
||||||
surface: Color(0xFFFFFFFF),
|
surface: Color(0xFFFFFFFF),
|
||||||
surfaceVariant: Color(0xFFEAEAEA),
|
surfaceVariant: Color(0xFFEAEAEA),
|
||||||
onSurface: Color(0xFF111111),
|
onSurface: Color(0xFF111111),
|
||||||
onSurfaceMuted: Color(0xFF777777),
|
onSurfaceMuted: Color(0xFF6E6E6E),
|
||||||
|
divider: Color(0x1F000000),
|
||||||
|
track: Color(0xFFDDDDDD),
|
||||||
);
|
);
|
||||||
|
|
||||||
static const greenDark = AppColors._(
|
static const redDark = _dark;
|
||||||
accent: Color(0xFF00E676),
|
static const redLight = _light;
|
||||||
background: Color(0xFF0A0A0A),
|
static final greenDark = _dark.copyWith(accent: const Color(0xFF00E676));
|
||||||
surface: Color(0xFF1A1A1A),
|
static final greenLight = _light.copyWith(accent: const Color(0xFF00875A));
|
||||||
surfaceVariant: Color(0xFF242424),
|
|
||||||
onSurface: Colors.white,
|
|
||||||
onSurfaceMuted: Colors.grey,
|
|
||||||
);
|
|
||||||
|
|
||||||
static const greenLight = AppColors._(
|
|
||||||
accent: Color(0xFF00875A),
|
|
||||||
background: Color(0xFFF2F2F2),
|
|
||||||
surface: Color(0xFFFFFFFF),
|
|
||||||
surfaceVariant: Color(0xFFEAEAEA),
|
|
||||||
onSurface: Color(0xFF111111),
|
|
||||||
onSurfaceMuted: Color(0xFF777777),
|
|
||||||
);
|
|
||||||
|
|
||||||
static AppColors forTheme(AppThemeVariant v) {
|
static AppColors forTheme(AppThemeVariant v) {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
@ -62,4 +59,45 @@ class AppColors {
|
|||||||
case AppThemeVariant.greenLight: return greenLight;
|
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)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ class AppTheme {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||||
scaffoldBackgroundColor: colors.background,
|
scaffoldBackgroundColor: colors.background,
|
||||||
|
extensions: [colors],
|
||||||
|
dividerColor: colors.divider,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: colors.accent,
|
seedColor: colors.accent,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||||
@ -39,7 +41,7 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: colors.surfaceVariant,
|
color: colors.divider,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
),
|
),
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/providers/bt_provider.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
|
/// 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.
|
/// 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 status = ref.watch(btProvider.select((s) => s.status));
|
||||||
final data = ref.watch(btProvider.select((s) => s.dataStatus));
|
final data = ref.watch(btProvider.select((s) => s.dataStatus));
|
||||||
final error = ref.watch(btProvider.select((s) => s.error));
|
final error = ref.watch(btProvider.select((s) => s.error));
|
||||||
|
final playing = ref.watch(playbackProvider.select((p) => p.isActive));
|
||||||
|
|
||||||
final String? msg;
|
final String? msg;
|
||||||
Color color = const Color(0xFF8A5A00);
|
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…';
|
msg = 'Connection interrupted — reconnecting…';
|
||||||
} else if (status == BtStatus.error) {
|
} else if (status == BtStatus.error) {
|
||||||
msg = error ?? 'Connection lost';
|
msg = error ?? 'Connection lost';
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import '../screens/sensor_list/sensor_list_screen.dart';
|
|||||||
import '../screens/graph/graph_screen.dart';
|
import '../screens/graph/graph_screen.dart';
|
||||||
import '../screens/dtc/dtc_screen.dart';
|
import '../screens/dtc/dtc_screen.dart';
|
||||||
import '../screens/datalog/datalog_list_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 '../screens/settings/settings_screen.dart';
|
||||||
import 'bt_status_chip.dart';
|
import 'bt_status_chip.dart';
|
||||||
import 'data_status_banner.dart';
|
import 'data_status_banner.dart';
|
||||||
@ -46,6 +47,7 @@ class MainNav extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final index = ref.watch(_navIndexProvider);
|
final index = ref.watch(_navIndexProvider);
|
||||||
|
final playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -60,14 +62,8 @@ class MainNav extends ConsumerWidget {
|
|||||||
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
// Record sits in the app bar so it never covers dashboard values.
|
||||||
icon: const Icon(Icons.bug_report_outlined),
|
const RecordButton(),
|
||||||
tooltip: 'ECU Frame Debug',
|
|
||||||
onPressed: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
@ -78,23 +74,28 @@ class MainNav extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
// SafeArea: in landscape the system navigation bar sits on a side.
|
||||||
children: [
|
body: SafeArea(
|
||||||
const DataStatusBanner(),
|
top: false,
|
||||||
Expanded(
|
bottom: false,
|
||||||
child: IndexedStack(
|
child: Column(
|
||||||
index: index,
|
children: [
|
||||||
children: _screens,
|
const DataStatusBanner(),
|
||||||
|
if (playback.isActive) DatalogPlaybackBar(state: playback),
|
||||||
|
Expanded(
|
||||||
|
child: IndexedStack(
|
||||||
|
index: index,
|
||||||
|
children: _screens,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
currentIndex: index,
|
currentIndex: index,
|
||||||
items: _items,
|
items: _items,
|
||||||
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
||||||
),
|
),
|
||||||
floatingActionButton: const RecordFab(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/providers/bt_provider.dart';
|
import '../../core/providers/bt_provider.dart';
|
||||||
import '../../core/providers/datalog_provider.dart';
|
import '../../core/providers/datalog_provider.dart';
|
||||||
|
|
||||||
class RecordFab extends ConsumerWidget {
|
/// App-bar record/stop button for datalogging (shown while connected).
|
||||||
const RecordFab({super.key});
|
class RecordButton extends ConsumerWidget {
|
||||||
|
const RecordButton({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@ -16,9 +17,8 @@ class RecordFab extends ConsumerWidget {
|
|||||||
|
|
||||||
final isRecording = recording.isRecording;
|
final isRecording = recording.isRecording;
|
||||||
|
|
||||||
return FloatingActionButton(
|
return IconButton(
|
||||||
heroTag: 'record_fab',
|
color: isRecording ? Colors.red : null,
|
||||||
backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
await ref.read(recordingProvider.notifier).stopRecording();
|
await ref.read(recordingProvider.notifier).stopRecording();
|
||||||
@ -37,11 +37,12 @@ class RecordFab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
||||||
child: AnimatedSwitcher(
|
icon: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: isRecording
|
child: isRecording
|
||||||
? const Icon(Icons.stop, key: ValueKey('stop'))
|
? const Icon(Icons.stop_circle, key: ValueKey('stop'))
|
||||||
: const Icon(Icons.fiber_manual_record, key: ValueKey('rec')),
|
: const Icon(Icons.fiber_manual_record_outlined,
|
||||||
|
key: ValueKey('rec')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
96
test/units_and_flags_test.dart
Normal file
96
test/units_and_flags_test.dart
Normal 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>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user