Dash page and smart-serow's palette, boxed widgets, attitude and G-meter
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 9s
CI / Debug APK (push) Successful in 17s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-13 23:25:28 +09:00
parent eae93b0697
commit b083cf31dd
9 changed files with 1270 additions and 13 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"dark": {
"background": "#000000",
"foreground": "#FFFFFF",
"highlight": "#FF5555",
"subdued": "#808080"
},
"bright": {
"background": "#F0F0F0",
"foreground": "#1A1A1A",
"highlight": "#CC0000",
"subdued": "#606060"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"dark": {
"background": "#303030",
"foreground": "#EAEAEA",
"highlight": "#FA1504",
"subdued": "#fda052"
},
"bright": {
"background": "#fda052",
"foreground": "#303030",
"highlight": "#df2100",
"subdued": "#EAEAEA"
}
}
+266
View File
@@ -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,
),
),
],
),
);
}
}
+23 -6
View File
@@ -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<void> 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<void> main() async {
final navigatorKey = GlobalKey<NavigatorState>();
/// 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,20 +83,22 @@ class YukartApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
// 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: ThemeData(
colorSchemeSeed: Colors.teal,
brightness: Brightness.dark,
useMaterial3: true,
),
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<void>(
builder: (_) => DashPage(boundary: b, themes: themes),
),
),
icon: const Icon(Icons.dashboard),
label: const Text('Dash'),
),
TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
+146
View File
@@ -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<String, dynamic> 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<Themes> load([String name = themeName]) async {
for (final n in {name, 'default'}) {
try {
final j = jsonDecode(
await rootBundle.loadString('assets/themes/$n.json'),
) as Map<String, dynamic>;
return Themes(
n,
Palette._fromJson(j['dark'] as Map<String, dynamic>),
Palette._fromJson(j['bright'] as Map<String, dynamic>),
);
} 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<AppTheme>();
assert(t != null, 'No AppTheme above this widget');
return t?.palette ?? Palette.fallbackDark;
}
@override
bool updateShouldNotify(AppTheme old) => palette != old.palette;
}
+340
View File
@@ -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<AccelGraph> createState() => _AccelGraphState();
}
class _AccelGraphState extends State<AccelGraph> {
// 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<Offset> 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;
}
}
+191
View File
@@ -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,
),
),
],
);
},
),
);
}
}
+267
View File
@@ -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;
}
}
+2
View File
@@ -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