From b083cf31dd1e7fc86882828517f6cc94baedc0c7 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Tue, 13 Oct 2026 23:25:28 +0900 Subject: [PATCH] Dash page and smart-serow's palette, boxed widgets, attitude and G-meter Ported from smart-serow's HUD: the four-colour palette with dark/bright variants (same theme JSON format, loaded at runtime; YUKART_THEME picks one), StatBox and StatBoxMain, WhiskeyMark and AccelGraph. The whole app takes its colours from the palette. Dash: attitude, G-meter, RPM, GNSS speed, pedal, coolant and telltales, each from its contracts signal; missing and stale draw in subdued. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz --- assets/themes/default.json | 14 ++ assets/themes/rei.json | 14 ++ lib/dash.dart | 266 ++++++++++++++++++++++++++ lib/main.dart | 43 +++-- lib/theme/palette.dart | 146 +++++++++++++++ lib/widgets/accel_graph.dart | 340 ++++++++++++++++++++++++++++++++++ lib/widgets/stat_box.dart | 191 +++++++++++++++++++ lib/widgets/whiskey_mark.dart | 267 ++++++++++++++++++++++++++ pubspec.yaml | 2 + 9 files changed, 1270 insertions(+), 13 deletions(-) create mode 100644 assets/themes/default.json create mode 100644 assets/themes/rei.json create mode 100644 lib/dash.dart create mode 100644 lib/theme/palette.dart create mode 100644 lib/widgets/accel_graph.dart create mode 100644 lib/widgets/stat_box.dart create mode 100644 lib/widgets/whiskey_mark.dart diff --git a/assets/themes/default.json b/assets/themes/default.json new file mode 100644 index 0000000..5b52a37 --- /dev/null +++ b/assets/themes/default.json @@ -0,0 +1,14 @@ +{ + "dark": { + "background": "#000000", + "foreground": "#FFFFFF", + "highlight": "#FF5555", + "subdued": "#808080" + }, + "bright": { + "background": "#F0F0F0", + "foreground": "#1A1A1A", + "highlight": "#CC0000", + "subdued": "#606060" + } +} diff --git a/assets/themes/rei.json b/assets/themes/rei.json new file mode 100644 index 0000000..74908bd --- /dev/null +++ b/assets/themes/rei.json @@ -0,0 +1,14 @@ +{ + "dark": { + "background": "#303030", + "foreground": "#EAEAEA", + "highlight": "#FA1504", + "subdued": "#fda052" + }, + "bright": { + "background": "#fda052", + "foreground": "#303030", + "highlight": "#df2100", + "subdued": "#EAEAEA" + } +} diff --git a/lib/dash.dart b/lib/dash.dart new file mode 100644 index 0000000..5a5dc1f --- /dev/null +++ b/lib/dash.dart @@ -0,0 +1,266 @@ +// 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: '◀', + reading: r('indicator_left'), + ), + Telltale( + label: '▶', + 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.warn = false, + this.flex = 1, + }); + + final String label; + 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; + 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 17fc5e0..4b7ff34 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,8 +8,10 @@ import 'package:flutter/material.dart'; import 'boundary.dart'; import 'camview.dart'; import 'checklist.dart'; +import 'dash.dart'; import 'linktest.dart'; import 'net.dart'; +import 'theme/palette.dart'; /// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`. const defaultUrl = String.fromEnvironment( @@ -19,6 +21,7 @@ const defaultUrl = String.fromEnvironment( Future main() async { WidgetsFlutterBinding.ensureInitialized(); + themes = await Themes.load(); final usb = UsbLink(); await usb.bindFor(Uri.parse(defaultUrl)); final boundary = Boundary(defaultUrl)..start(); @@ -58,6 +61,9 @@ Future main() async { final navigatorKey = GlobalKey(); +/// The palette (lib/theme/palette.dart), loaded before the first frame. +late final Themes themes; + Widget linkTestPage(Boundary b, LinkTest t, UsbLink usb) => LinkTestPage( test: t, host: Uri.parse(b.url).host, @@ -77,19 +83,21 @@ class YukartApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Yukart', - navigatorKey: navigatorKey, - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorSchemeSeed: Colors.teal, - brightness: Brightness.dark, - useMaterial3: true, - ), - home: ListenableBuilder( - listenable: Listenable.merge([boundary, usb]), - builder: (context, _) => - Readout(boundary: boundary, usb: usb, linkTest: linkTest), + // Every page takes its colours from the palette; switching dark/bright rebuilds them all. + return ListenableBuilder( + listenable: themes, + builder: (context, _) => MaterialApp( + title: 'Yukart', + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + theme: themes.material(), + builder: (context, child) => + AppTheme(palette: themes.current, child: child!), + home: ListenableBuilder( + listenable: Listenable.merge([boundary, usb]), + builder: (context, _) => + Readout(boundary: boundary, usb: usb, linkTest: linkTest), + ), ), ); } @@ -233,6 +241,15 @@ class LinkBar extends StatelessWidget { Text( 'USB link ${usb.up ? 'up' : 'absent'}${usb.bound ? ', bound' : ''}', ), + TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DashPage(boundary: b, themes: themes), + ), + ), + icon: const Icon(Icons.dashboard), + label: const Text('Dash'), + ), TextButton.icon( onPressed: () => Navigator.of(context).push( MaterialPageRoute( diff --git a/lib/theme/palette.dart b/lib/theme/palette.dart new file mode 100644 index 0000000..9623072 --- /dev/null +++ b/lib/theme/palette.dart @@ -0,0 +1,146 @@ +// The palette system, from smart-serow's HUD: four semantic colours, each with a dark +// (low ambient light) and a bright (high ambient light) variant, read from a theme +// JSON in assets/themes/. Same file format as smart-serow's extra/themes/, so themes +// move between the two as they are. Widgets ask for meanings, never for colours: +// +// final p = AppTheme.of(context); +// color: p.foreground +// +// Pick the theme at build time: `--dart-define=YUKART_THEME=rei` (default: default). + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +const themeName = String.fromEnvironment( + 'YUKART_THEME', + defaultValue: 'default', +); + +/// One variant (dark or bright) of a theme. +class Palette { + const Palette({ + required this.background, + required this.foreground, + required this.highlight, + required this.subdued, + }); + + /// Behind everything. + final Color background; + + /// Values: what is read at a glance. + final Color foreground; + + /// Warnings, and the one thing that must be seen. + final Color highlight; + + /// Labels, units, grids, and anything missing or stale. + final Color subdued; + + static Palette _fromJson(Map j) { + Color c(String k) => + Color(0xFF000000 | int.parse((j[k] as String).substring(1), radix: 16)); + return Palette( + background: c('background'), + foreground: c('foreground'), + highlight: c('highlight'), + subdued: c('subdued'), + ); + } + + /// smart-serow's hard-coded last resort, used when no theme file loads. + static const fallbackDark = Palette( + background: Color(0xFF000000), + foreground: Color(0xFFFFFFFF), + highlight: Color(0xFFFF5555), + subdued: Color(0xFF808080), + ); + static const fallbackBright = Palette( + background: Color(0xFFF0F0F0), + foreground: Color(0xFF1A1A1A), + highlight: Color(0xFFCC0000), + subdued: Color(0xFF606060), + ); +} + +/// A theme: both variants, and which one is showing. Listen to it to follow a switch. +class Themes extends ChangeNotifier { + Themes(this.name, this.dark, this.bright); + + final String name; + final Palette dark; + final Palette bright; + bool _isDark = true; + + bool get isDark => _isDark; + Palette get current => _isDark ? dark : bright; + + set isDark(bool v) { + if (v == _isDark) return; + _isDark = v; + notifyListeners(); + } + + void toggle() => isDark = !_isDark; + + /// [themeName], falling back to `default`, then to the hard-coded palette + /// (smart-serow's chain). + static Future load([String name = themeName]) async { + for (final n in {name, 'default'}) { + try { + final j = jsonDecode( + await rootBundle.loadString('assets/themes/$n.json'), + ) as Map; + return Themes( + n, + Palette._fromJson(j['dark'] as Map), + Palette._fromJson(j['bright'] as Map), + ); + } catch (_) { + // Missing or malformed: try the next one. + } + } + return Themes('fallback', Palette.fallbackDark, Palette.fallbackBright); + } + + /// Material's theme for the rest of the app, from the same four colours. + ThemeData material() { + final p = current; + final brightness = _isDark ? Brightness.dark : Brightness.light; + return ThemeData( + useMaterial3: true, + brightness: brightness, + scaffoldBackgroundColor: p.background, + colorScheme: + ColorScheme.fromSeed( + seedColor: p.highlight, + brightness: brightness, + ).copyWith( + surface: p.background, + onSurface: p.foreground, + primary: p.highlight, + error: p.highlight, + outline: p.subdued, + onSurfaceVariant: p.subdued, + ), + ); + } +} + +/// The current palette, for any widget below it: `AppTheme.of(context).foreground`. +class AppTheme extends InheritedWidget { + const AppTheme({super.key, required this.palette, required super.child}); + + final Palette palette; + + static Palette of(BuildContext context) { + final t = context.dependOnInheritedWidgetOfExactType(); + assert(t != null, 'No AppTheme above this widget'); + return t?.palette ?? Palette.fallbackDark; + } + + @override + bool updateShouldNotify(AppTheme old) => palette != old.palette; +} diff --git a/lib/widgets/accel_graph.dart b/lib/widgets/accel_graph.dart new file mode 100644 index 0000000..f740b56 --- /dev/null +++ b/lib/widgets/accel_graph.dart @@ -0,0 +1,340 @@ +// Ported from smart-serow (pi/ui/lib/widgets/accel_graph.dart): the HUD unit's G-meter. +// Changes: the palette import, lint fixes, and "—G" (not "—°") for a missing value. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../theme/palette.dart'; + +/// 2D lateral G-meter showing acceleration as a dot on a cartesian grid. +/// +/// Visual: square grid with dot position = (ax, ay) scaled by maxG +/// Optional ghost dot tracks peak magnitude within ghostTrackPeriod window. +class AccelGraph extends StatefulWidget { + /// X-axis acceleration in g (lateral: negative = left, positive = right) + final double? ax; + + /// Y-axis acceleration in g (longitudinal: negative = back, positive = forward) + final double? ay; + + /// Maximum G range for the grid (default 2.0 = ±2G) + final double maxG; + + /// If set, shows a ghost dot at peak magnitude position, resetting after this duration + final Duration? ghostTrackPeriod; + + const AccelGraph({ + super.key, + this.ax, + this.ay, + this.maxG = 2.0, + this.ghostTrackPeriod, + }); + + @override + State createState() => _AccelGraphState(); +} + +class _AccelGraphState extends State { + // Ghost dot tracking + double _ghostAx = 0; + double _ghostAy = 0; + double _ghostMagnitude = 0; + + // Timestamped history for sliding window + final List<({DateTime time, double ax, double ay})> _history = []; + + @override + void didUpdateWidget(AccelGraph oldWidget) { + super.didUpdateWidget(oldWidget); + + final currentAx = widget.ax ?? 0; + final currentAy = widget.ay ?? 0; + final now = DateTime.now(); + + // Only track history when ghostTrackPeriod is configured + if (widget.ghostTrackPeriod != null) { + // Add current reading to history + _history.add((time: now, ax: currentAx, ay: currentAy)); + + // Prune entries outside the window + final cutoff = now.subtract(widget.ghostTrackPeriod!); + _history.removeWhere((e) => e.time.isBefore(cutoff)); + + // Recalculate ghost as max magnitude from current window + _ghostAx = currentAx; + _ghostAy = currentAy; + _ghostMagnitude = 0; + + for (final entry in _history) { + final mag = math.sqrt(entry.ax * entry.ax + entry.ay * entry.ay); + if (mag > _ghostMagnitude) { + _ghostAx = entry.ax; + _ghostAy = entry.ay; + _ghostMagnitude = mag; + } + } + } else { + // No window configured - clear history to save memory + _history.clear(); + } + } + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final size = math.min(constraints.maxWidth, constraints.maxHeight); + final gridSize = size * 0.6; + final fontSize = size * 0.12; + final strokeSize = size * 0.015; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // G-meter grid + SizedBox( + width: gridSize, + height: gridSize, + child: CustomPaint( + painter: _AccelGraphPainter( + ax: widget.ax ?? 0, + ay: widget.ay ?? 0, + ghostAx: _ghostAx, + ghostAy: _ghostAy, + showGhost: + widget.ghostTrackPeriod != null && _ghostMagnitude > 0, + maxG: widget.maxG, + foreground: theme.foreground, + subdued: theme.subdued, + background: theme.background, + strokeWeight: strokeSize, + traceBuffer: _history.map((e) => Offset(e.ax, e.ay)).toList(), + ), + ), + ), + + SizedBox(height: size * 0.03), + + // Numeric readout + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Lon: ${_formatAccel(widget.ay)} (${_formatAccel(_ghostAy)})', + style: TextStyle( + fontSize: fontSize * 0.5, + fontWeight: FontWeight.w400, + fontFeatures: const [FontFeature.tabularFigures()], + color: theme.foreground, + ), + ), + SizedBox(width: size * 0.1), + Text( + 'Lat: ${_formatAccel(widget.ax)} (${_formatAccel(_ghostAx)})', + style: TextStyle( + fontSize: fontSize * 0.5, + fontWeight: FontWeight.w400, + fontFeatures: const [FontFeature.tabularFigures()], + color: theme.subdued, + ), + ), + ], + ), + + // Label + Text( + 'Acceleration', + style: TextStyle( + fontSize: fontSize * 0.8, + fontWeight: FontWeight.w400, + color: theme.subdued, + letterSpacing: 1, + ), + ), + ], + ); + }, + ); + } + + String _formatAccel(double? force) { + if (force == null) return '—G'; + return '${force.toStringAsFixed(1) == '-0.0' ? '0.0' : force.toStringAsFixed(1)}G'; + } +} + +/// Custom painter for the G-meter grid and dots +class _AccelGraphPainter extends CustomPainter { + final double ax; + final double ay; + final double ghostAx; + final double ghostAy; + final bool showGhost; + final double maxG; + final Color foreground; + final Color subdued; + final Color background; + final double strokeWeight; + final List traceBuffer; + + _AccelGraphPainter({ + required this.ax, + required this.ay, + required this.ghostAx, + required this.ghostAy, + required this.showGhost, + required this.maxG, + required this.foreground, + required this.subdued, + required this.background, + required this.strokeWeight, + required this.traceBuffer, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final halfSize = size.width / 2; + final radius = math.min(size.width, size.height) / 2; + + canvas.clipPath( + Path()..addOval(Rect.fromCircle(center: center, radius: radius)), + ); + + // No rectangular border + + // Grid lines at 0.25G intervals + final gridPaint = Paint() + ..color = subdued + ..strokeWidth = strokeWeight * 0.4 + ..style = PaintingStyle.stroke; + + const gStep = 0.25; + for (double g = gStep; g < maxG; g += gStep) { + final offset = (g / maxG) * halfSize; + + // Vertical lines (left and right of center) + canvas.drawLine( + Offset(center.dx - offset, 0), + Offset(center.dx - offset, size.height), + gridPaint, + ); + canvas.drawLine( + Offset(center.dx + offset, 0), + Offset(center.dx + offset, size.height), + gridPaint, + ); + + // Horizontal lines (above and below center) + canvas.drawLine( + Offset(0, center.dy - offset), + Offset(size.width, center.dy - offset), + gridPaint, + ); + canvas.drawLine( + Offset(0, center.dy + offset), + Offset(size.width, center.dy + offset), + gridPaint, + ); + } + + // Center axis lines (heavier) + final axisPaint = Paint() + ..color = subdued + ..strokeWidth = strokeWeight + ..style = PaintingStyle.stroke; + + // Horizontal axis + canvas.drawLine( + Offset(0, center.dy), + Offset(size.width, center.dy), + axisPaint, + ); + // Vertical axis + canvas.drawLine( + Offset(center.dx, 0), + Offset(center.dx, size.height), + axisPaint, + ); + + // G-ring markers (circles at every 0.5G for quick reference) + final ringPaint = Paint() + ..color = subdued + ..strokeWidth = strokeWeight * 0.5 + ..style = PaintingStyle.stroke; + + for (double g = 0.5; g <= maxG; g += 0.5) { + final radius = (g / maxG) * halfSize; + canvas.drawCircle(center, radius, ringPaint); + } + + // Trace line + if (traceBuffer.length >= 2) { + final tracePaint = Paint() + ..color = foreground + ..strokeWidth = strokeWeight * 0.4 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + final path = Path(); + for (int i = 0; i < traceBuffer.length; i++) { + final pt = traceBuffer[i]; + final x = center.dx + (pt.dx.clamp(-maxG, maxG) / maxG) * halfSize; + final y = center.dy - (pt.dy.clamp(-maxG, maxG) / maxG) * halfSize; + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + canvas.drawPath(path, tracePaint); + } + + // Ghost dot (if enabled and has data) + if (showGhost) { + final ghostX = center.dx + (ghostAx / maxG) * halfSize; + final ghostY = + center.dy - (ghostAy / maxG) * halfSize; // Y inverted (up = positive) + final ghostRadius = halfSize * 0.08; + + final ghostPaint = Paint() + ..color = subdued + ..strokeWidth = strokeWeight + ..style = PaintingStyle.stroke; + + canvas.drawCircle(Offset(ghostX, ghostY), ghostRadius, ghostPaint); + } + + // Main dot - clamp to grid bounds + final clampedAx = ax.clamp(-maxG, maxG); + final clampedAy = ay.clamp(-maxG, maxG); + final dotX = center.dx + (clampedAx / maxG) * halfSize; + final dotY = + center.dy - (clampedAy / maxG) * halfSize; // Y inverted (up = positive) + final dotRadius = halfSize * 0.1; + + final dotPaint = Paint() + ..color = foreground + ..style = PaintingStyle.fill; + + canvas.drawCircle(Offset(dotX, dotY), dotRadius, dotPaint); + } + + @override + bool shouldRepaint(_AccelGraphPainter oldDelegate) { + return ax != oldDelegate.ax || + ay != oldDelegate.ay || + ghostAx != oldDelegate.ghostAx || + ghostAy != oldDelegate.ghostAy || + showGhost != oldDelegate.showGhost || + maxG != oldDelegate.maxG || + foreground != oldDelegate.foreground || + subdued != oldDelegate.subdued || + traceBuffer != oldDelegate.traceBuffer; + } +} diff --git a/lib/widgets/stat_box.dart b/lib/widgets/stat_box.dart new file mode 100644 index 0000000..f1dd4ac --- /dev/null +++ b/lib/widgets/stat_box.dart @@ -0,0 +1,191 @@ +// The boxed-widget pattern from smart-serow's HUD (pi/ui/lib/widgets/stat_box*.dart): +// every readout is a box that takes its share of a row (`flex`) and sizes its type +// from its own height, so a layout is a grid of boxes, and a value changing digits +// never moves anything. Value in foreground with tabular figures, unit and label in +// subdued; a warning turns the value to highlight. +// +// Added for Yukart: [dim]. A missing or stale value is a normal state in a car +// (app guideline 2), shown in subdued rather than hidden. + +import 'package:flutter/material.dart'; + +import '../theme/palette.dart'; + +/// A labeled stat box: the small one, for rows of readouts. +class StatBox extends StatelessWidget { + const StatBox({ + super.key, + required this.value, + this.unit, + required this.label, + this.flex = 1, + this.isWarning, + this.dim = false, + }); + + final String value; + final String? unit; + final String label; + final int flex; + + /// If it returns true, the value shows in highlight. + final bool Function()? isWarning; + + /// Missing or stale: the value shows in subdued. + final bool dim; + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + final warning = isWarning?.call() ?? false; + final valueColor = dim + ? theme.subdued + : warning + ? theme.highlight + : theme.foreground; + + return Expanded( + flex: flex, + child: LayoutBuilder( + builder: (context, constraints) { + final baseSize = constraints.maxHeight * 0.4; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Value + optional unit + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + value, + style: TextStyle( + fontSize: baseSize, + fontWeight: FontWeight.w400, + fontFeatures: const [FontFeature.tabularFigures()], + color: valueColor, + height: 1, + ), + ), + SizedBox(width: baseSize * 0.1), + if (unit != null) ...[ + const SizedBox(width: 4), + Text( + unit!, + style: TextStyle( + fontSize: baseSize * 0.5, + fontWeight: FontWeight.w400, + color: theme.subdued, + height: 1, + ), + ), + ], + ], + ), + SizedBox(height: baseSize * 0.1), + // Label + Text( + label, + style: TextStyle( + fontSize: baseSize * 0.6, + fontWeight: FontWeight.w400, + color: theme.subdued, + letterSpacing: 1, + ), + ), + ], + ); + }, + ), + ); + } +} + +/// The large stat box, for the main area. Fixed-size: content changes don't move the layout. +class StatBoxMain extends StatelessWidget { + const StatBoxMain({ + super.key, + required this.value, + this.unit, + required this.label, + this.flex = 1, + this.isWarning, + this.dim = false, + }); + + final String value; + final String? unit; + final String label; + final int flex; + final bool Function()? isWarning; + final bool dim; + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + final warning = isWarning?.call() ?? false; + + return Expanded( + flex: flex, + child: LayoutBuilder( + builder: (context, constraints) { + // Scale fonts relative to box height for consistent proportions + final baseSize = constraints.maxHeight * 0.4; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Value + optional unit row + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + value, + style: TextStyle( + fontSize: baseSize, + fontWeight: FontWeight.w300, + fontFeatures: const [FontFeature.tabularFigures()], + color: dim + ? theme.subdued + : warning + ? theme.highlight + : theme.foreground, + height: 1, + ), + ), + if (unit != null) ...[ + const SizedBox(width: 8), + Text( + unit!, + style: TextStyle( + fontSize: baseSize * 0.4, + fontWeight: FontWeight.w400, + color: theme.subdued, + height: 1, + ), + ), + ], + ], + ), + SizedBox(height: baseSize * 0.1), + // Label + Text( + label, + style: TextStyle( + fontSize: baseSize * 0.35, + fontWeight: FontWeight.w400, + color: theme.subdued, + letterSpacing: 2, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/widgets/whiskey_mark.dart b/lib/widgets/whiskey_mark.dart new file mode 100644 index 0000000..f5ffeaf --- /dev/null +++ b/lib/widgets/whiskey_mark.dart @@ -0,0 +1,267 @@ +// Ported from smart-serow (pi/ui/lib/widgets/whiskey_mark.dart), unchanged but for the +// palette import: the HUD unit's attitude indicator. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../theme/palette.dart'; + +/// Primitive attitude indicator (whiskey mark) displaying roll/pitch. +/// +/// Visual: tilting horizon line based on roll angle +/// Hard left (-45°+): ╲ +/// Left (-15°): ╲─ +/// Level (0°): ─ +/// Right (+15°): ─╱ +/// Hard right (+45°+): ╱ +/// +/// Below the horizon: numeric readout "R: -12° P: 5°" +class WhiskeyMark extends StatelessWidget { + /// Roll angle in degrees. Negative = left bank, positive = right bank. + final double? roll; + + /// Pitch angle in degrees. Negative = nose down, positive = nose up. + final double? pitch; + + const WhiskeyMark({super.key, this.roll, this.pitch}); + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final size = math.min(constraints.maxWidth, constraints.maxHeight); + final horizonSize = size * 0.6; + final fontSize = size * 0.12; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Horizon indicator + SizedBox( + width: horizonSize, + height: horizonSize, + child: CustomPaint( + painter: _HorizonPainter( + roll: roll ?? 0, + pitch: pitch ?? 0, + lineColor: theme.foreground, + borderWeight: 8, + skyColor: theme.subdued, + groundColor: theme.background, + ), + ), + ), + + SizedBox(height: size * 0.05), + + // Numeric readout + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Roll: ${_formatAngle(roll)}', + style: TextStyle( + fontSize: fontSize * 0.5, + fontWeight: FontWeight.w400, + fontFeatures: const [FontFeature.tabularFigures()], + color: theme.foreground, + ), + ), + SizedBox(width: size * 0.1), + Text( + 'Pitch: ${_formatAngle(pitch)}', + style: TextStyle( + fontSize: fontSize * 0.5, + fontWeight: FontWeight.w400, + fontFeatures: const [FontFeature.tabularFigures()], + color: theme.subdued, + ), + ), + ], + ), + + // Label + Text( + 'Attitude', + style: TextStyle( + fontSize: fontSize * 0.8, + fontWeight: FontWeight.w400, + color: theme.subdued, + letterSpacing: 1, + ), + ), + ], + ); + }, + ); + } + + String _formatAngle(double? angle) { + if (angle == null) return '—°'; + return '${angle.round() > 180 ? angle.round() - 360 : angle.round()}°'; + } +} + +/// Custom painter for the tilting horizon line +class _HorizonPainter extends CustomPainter { + final double roll; + final double pitch; + final double borderWeight; + final Color lineColor; + final Color skyColor; + final Color groundColor; + + _HorizonPainter({ + required this.roll, + required this.pitch, + required this.borderWeight, + required this.lineColor, + required this.skyColor, + required this.groundColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2; + + // Clip to circle + canvas.save(); + canvas.clipPath( + Path()..addOval(Rect.fromCircle(center: center, radius: radius)), + ); + + // Convert roll to radians (negate so positive roll tilts right visually) + final rollRad = -roll * math.pi / 180; + + // Pitch offset (positive pitch moves horizon down, showing more sky) + // Scale: 90° pitch = full radius displacement + final pitchOffset = (pitch / 90) * radius; + + // Calculate horizon line endpoints + // The horizon is a horizontal line that we rotate by roll and offset by pitch + final horizonY = center.dy + pitchOffset; + + // Paint sky (above horizon) + final skyPaint = Paint()..color = skyColor; + final groundPaint = Paint()..color = groundColor; + + // Create rotated horizon path + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.rotate(rollRad); + canvas.translate(-center.dx, -center.dy); + + // Sky rectangle (above horizon) + canvas.drawRect( + Rect.fromLTRB( + center.dx - radius * 2, + center.dy - radius * 2, + center.dx + radius * 2, + horizonY, + ), + skyPaint, + ); + + // Ground rectangle (below horizon) + canvas.drawRect( + Rect.fromLTRB( + center.dx - radius * 2, + horizonY, + center.dx + radius * 2, + center.dy + radius * 2, + ), + groundPaint, + ); + + // Horizon line + final linePaint = Paint() + ..color = lineColor + ..strokeWidth = borderWeight * 0.1 + ..style = PaintingStyle.stroke; + + canvas.drawLine( + Offset(center.dx - radius, horizonY), + Offset(center.dx + radius, horizonY), + linePaint, + ); + + // Pitch ladder lines (15° intervals) + final ladderPaint = Paint() + ..color = lineColor + ..strokeWidth = borderWeight * 0.4 + ..style = PaintingStyle.stroke; + + for (int deg = -75; deg <= 75; deg += 15) { + if (deg == 0) continue; // Skip horizon (already drawn) + + final ladderY = horizonY - (deg / 90) * radius; + final double widthMod = (100 - (deg < 0 ? -deg : deg)) / 100; + final ladderWidth = + radius * 0.7 * widthMod; // longer ladder if close to horizon + + canvas.drawLine( + Offset(center.dx - ladderWidth, ladderY), + Offset(center.dx + ladderWidth, ladderY), + ladderPaint, + ); + } + + canvas.restore(); + + // Draw circle border + final borderPaint = Paint() + ..color = lineColor + ..strokeWidth = borderWeight * 1.1 + ..style = PaintingStyle.stroke; + + canvas.drawCircle(center, radius - 1, borderPaint); + + // Draw center reference mark (fixed, doesn't rotate) + final refPaint = Paint() + ..color = lineColor + ..strokeWidth = borderWeight + ..style = PaintingStyle.stroke; + + // Small wings + canvas.drawLine( + Offset(center.dx - radius * 0.3, center.dy), + Offset(center.dx - radius * 0.1, center.dy), + refPaint, + ); + canvas.drawLine( + Offset(center.dx + radius * 0.1, center.dy), + Offset(center.dx + radius * 0.3, center.dy), + refPaint, + ); + + // Center arrow + final refTipPaint = Paint() + ..color = lineColor + ..strokeWidth = borderWeight * 0.8 + ..style = PaintingStyle.stroke; + + canvas.drawLine( + Offset(center.dx, center.dy), + Offset(center.dx + radius * 0.07, center.dy + radius * 0.1), + refTipPaint, + ); + canvas.drawLine( + Offset(center.dx, center.dy), + Offset(center.dx - radius * 0.07, center.dy + radius * 0.1), + refTipPaint, + ); + + canvas.restore(); + } + + @override + bool shouldRepaint(_HorizonPainter oldDelegate) { + return roll != oldDelegate.roll || + pitch != oldDelegate.pitch || + lineColor != oldDelegate.lineColor; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e1ad0d1..9820442 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,8 @@ flutter: # Exact copies of contracts files (tool/sync-contracts.sh); crosscheck verifies them. assets: - assets/contracts/checklists/ + # Palettes: smart-serow's theme JSON format (lib/theme/palette.dart). + - assets/themes/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images