// The software dash: smart-serow's HUD layout and widgets, fed from the boundary. // A grid of boxes (lib/widgets/stat_box.dart): the instruments and the big numbers // in the main row, smaller readouts and telltales below. Every value names its // signal from contracts' signals.json and nothing else (app guideline 1). import 'package:flutter/material.dart'; import 'boundary.dart'; import 'theme/palette.dart'; import 'widgets/accel_graph.dart'; import 'widgets/stat_box.dart'; import 'widgets/whiskey_mark.dart'; /// One signal's current state, as the dash needs it. class Reading { const Reading(this.value, {this.dim = false}); /// The value, or null when missing or known-without-value. final Object? value; /// Missing or stale: drawn in subdued. final bool dim; static Reading of(Boundary b, String name) { final e = b.signals[name]; if (e is! Map) return const Reading(null, dim: true); final q = e['quality'] as String? ?? 'ok'; return Reading(e['value'], dim: q != 'ok' || e['value'] == null); } double? get number => value is num ? (value as num).toDouble() : null; bool get on => value == true; String fixed([int decimals = 0]) => number == null ? '—' : number!.toStringAsFixed(decimals); } class DashPage extends StatelessWidget { const DashPage({super.key, required this.boundary, required this.themes}); final Boundary boundary; final Themes themes; @override Widget build(BuildContext context) { return ListenableBuilder( listenable: Listenable.merge([boundary, themes]), builder: (context, _) { final p = AppTheme.of(context); final b = boundary; Reading r(String n) => Reading.of(b, n); final rpm = r('engine_rpm'); final speed = r('gnss_speed_kph'); final pedal = r('accel_pedal_pct'); final coolant = r('obd_coolant_temp_c'); final lat = r('imu_accel_lat_g'); final lon = r('imu_accel_lon_g'); return Scaffold( backgroundColor: p.background, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (b.mock) Container( color: p.highlight, padding: const EdgeInsets.symmetric(vertical: 4), child: Text( 'MOCK DATA — NOT FROM THE CAR', textAlign: TextAlign.center, style: TextStyle( color: p.background, fontWeight: FontWeight.w900, letterSpacing: 2, ), ), ), _DashBar(boundary: b, themes: themes), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( children: [ // Main area: instruments and the big numbers. Expanded( flex: 7, child: Row( children: [ Expanded( child: WhiskeyMark( roll: r('imu_roll_deg').number, pitch: r('imu_pitch_deg').number, ), ), Expanded( child: AccelGraph( ax: lat.number, ay: lon.number, maxG: 0.8, ghostTrackPeriod: const Duration(seconds: 4), ), ), Expanded( child: Column( children: [ StatBoxMain( value: rpm.fixed(), label: 'RPM', dim: rpm.dim, isWarning: () => (rpm.number ?? 0) > 6000, ), StatBoxMain( value: speed.fixed(), unit: 'km/h', label: 'GNSS SPEED', dim: speed.dim, ), ], ), ), ], ), ), // Bottom row: smaller readouts and telltales. Expanded( flex: 2, child: Row( children: [ StatBox( value: pedal.fixed(), unit: '%', label: 'PEDAL', dim: pedal.dim, ), StatBox( value: coolant.fixed(), unit: '°C', label: 'COOLANT', dim: coolant.dim, isWarning: () => (coolant.number ?? 0) > 105, ), Telltale( label: 'LEFT', icon: Icons.arrow_left, reading: r('indicator_left'), ), Telltale( label: 'RIGHT', icon: Icons.arrow_right, reading: r('indicator_right'), ), Telltale( label: 'BRAKE', reading: r('brake_pedal'), ), Telltale( label: 'PARK', reading: r('handbrake'), warn: true, ), Telltale( label: 'LIGHTS', reading: r('side_lights'), ), Telltale( label: 'DOOR', reading: r('door_open'), warn: true, ), ], ), ), ], ), ), ), ], ), ), ); }, ); } } /// An on/off lamp in the boxed pattern: the label is the lamp. Lit in foreground /// (highlight when [warn]), dark in subdued; missing shows as a dash. class Telltale extends StatelessWidget { const Telltale({ super.key, required this.label, required this.reading, this.icon, this.warn = false, this.flex = 1, }); final String label; /// Drawn instead of the label when set. Glyphs like ◀ ▶ come out as emoji on Android. final IconData? icon; final Reading reading; final bool warn; final int flex; @override Widget build(BuildContext context) { final p = AppTheme.of(context); final missing = reading.value == null; final lit = reading.on && !reading.dim; final color = lit ? (warn ? p.highlight : p.foreground) : p.subdued.withValues(alpha: missing ? 0.35 : 0.6); return Expanded( flex: flex, child: LayoutBuilder( builder: (context, c) { final size = c.maxHeight * 0.3; if (icon != null && !missing) { return Center( child: Icon( icon, size: c.maxHeight * 0.7, color: color, semanticLabel: label, ), ); } return Center( child: Text( missing ? '—' : label, style: TextStyle( fontSize: size, fontWeight: lit ? FontWeight.w700 : FontWeight.w400, color: color, letterSpacing: 1, ), ), ); }, ), ); } } /// The dash's own thin top line: link state, back, and the dark/bright switch. class _DashBar extends StatelessWidget { const _DashBar({required this.boundary, required this.themes}); final Boundary boundary; final Themes themes; @override Widget build(BuildContext context) { final p = AppTheme.of(context); final (text, color) = switch (boundary.state) { LinkState.up => ('LINK', p.subdued), LinkState.connecting => ('CONNECTING', p.highlight), LinkState.down => ('NO LINK', p.highlight), }; return SizedBox( height: 40, child: Row( children: [ IconButton( onPressed: () => Navigator.of(context).maybePop(), icon: Icon(Icons.arrow_back, color: p.subdued), ), Text(text, style: TextStyle(color: color, letterSpacing: 2)), const Spacer(), IconButton( tooltip: themes.isDark ? 'Bright' : 'Dark', onPressed: themes.toggle, icon: Icon( themes.isDark ? Icons.light_mode : Icons.dark_mode, color: p.subdued, ), ), ], ), ); } }