Dash: DIN 1451 opt-in per widget (hudFont), GNSS compass, rei by default; widget catalogue
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 14s
CI / Debug APK (push) Successful in 36s

From smart-serow: the DIN 1451 face (Alte DIN 1451 Mittelschrift, via gps-speedo)
and GpsCompass. A cosmetic font is opt-in: widgets name hudFont in their own
TextStyle, the rest keeps the basic font (CJK). Boxes and telltales shrink long
labels instead of wrapping. Telltale and Reading get their own files;
lib/README.md catalogues folders and widgets like smart-serow's.

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-14 13:20:34 +09:00
parent 728804e971
commit 8d95514897
11 changed files with 360 additions and 167 deletions
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
---
type: reference
---
# The app's code: a catalogue
Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from.
## Folders
| Where | Holds |
|---|---|
| `lib/*.dart` | Pages (`dash`, `checklist`, `camview`, `linktest`), the boundary client (`boundary`), the USB network binding (`net`), and `reading`, one signal's state as a display needs it |
| `theme/` | `palette.dart`: the palette and `hudFont` |
| `widgets/` | Reusable display widgets, one per file, each fed values, never the boundary |
## Widgets
| Widget | File | Shows | From |
|---|---|---|---|
| `StatBox`, `StatBoxMain` | `widgets/stat_box.dart` | A value, its unit and label, sized from its box's height; highlight on warning, subdued when dim | smart-serow |
| `Telltale` | `widgets/telltale.dart` | An on/off lamp: a label or an icon, lit or dark, "—" when missing | Yukart |
| `WhiskeyMark` | `widgets/whiskey_mark.dart` | Attitude: a horizon tilting with roll, ladder moving with pitch | smart-serow |
| `AccelGraph` | `widgets/accel_graph.dart` | G-meter: a dot for lateral/longitudinal g, a trace and a peak marker over a window | smart-serow |
| `GpsCompass` | `widgets/gps_compass.dart` | Heading arrow and bearing; "ACQ" while acquiring, "N/A" without | smart-serow, from gps-speedo |
## Conventions
- **The boxed pattern.** A widget takes its share of a row and sizes its type from its own height, so a layout
is a grid of boxes and a value gaining a digit never moves anything. Labels shrink to fit, never wrap.
- **Colours by meaning.** `AppTheme.of(context)` gives the palette's `background`, `foreground`, `highlight` and
`subdued`; no widget names a colour. Themes are smart-serow's JSON (`assets/themes/`), `rei` by default.
- **Cosmetic fonts are opt-in.** A widget that wants the HUD face names `fontFamily: hudFont` in its own
`TextStyle`; everything else keeps the basic font, which covers CJK where DIN 1451 does not.
- **Missing is a state** (app guideline 2): a widget given no value draws "—" or subdued, never an error.
+16 -84
View File
@@ -6,35 +6,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'boundary.dart'; import 'boundary.dart';
import 'reading.dart';
import 'theme/palette.dart'; import 'theme/palette.dart';
import 'widgets/accel_graph.dart'; import 'widgets/accel_graph.dart';
import 'widgets/gps_compass.dart';
import 'widgets/stat_box.dart'; import 'widgets/stat_box.dart';
import 'widgets/telltale.dart';
import 'widgets/whiskey_mark.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 { class DashPage extends StatelessWidget {
const DashPage({super.key, required this.boundary, required this.themes}); const DashPage({super.key, required this.boundary, required this.themes});
final Boundary boundary; final Boundary boundary;
@@ -54,6 +33,13 @@ class DashPage extends StatelessWidget {
final coolant = r('obd_coolant_temp_c'); final coolant = r('obd_coolant_temp_c');
final lat = r('imu_accel_lat_g'); final lat = r('imu_accel_lat_g');
final lon = r('imu_accel_lon_g'); final lon = r('imu_accel_lon_g');
// The compass's states, from gnss_fix: none → acquiring, 2d/3d → fix, missing → none known.
final fix = r('gnss_fix');
final gpsState = fix.value == null
? null
: fix.value == 'none'
? 'acquiring'
: 'fix';
return Scaffold( return Scaffold(
backgroundColor: p.background, backgroundColor: p.background,
@@ -139,6 +125,12 @@ class DashPage extends StatelessWidget {
dim: coolant.dim, dim: coolant.dim,
isWarning: () => (coolant.number ?? 0) > 105, isWarning: () => (coolant.number ?? 0) > 105,
), ),
Expanded(
child: GpsCompass(
heading: r('gnss_heading_deg').number,
gpsState: gpsState,
),
),
Telltale( Telltale(
label: 'LEFT', label: 'LEFT',
icon: Icons.arrow_left, icon: Icons.arrow_left,
@@ -183,66 +175,6 @@ class DashPage extends StatelessWidget {
} }
} }
/// 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. /// The dash's own thin top line: link state, back, and the dark/bright switch.
class _DashBar extends StatelessWidget { class _DashBar extends StatelessWidget {
const _DashBar({required this.boundary, required this.themes}); const _DashBar({required this.boundary, required this.themes});
+28
View File
@@ -0,0 +1,28 @@
// One signal's current state as a display needs it: value, and whether to draw it dim
// (missing, stale or known-without-value; app guideline 2).
import 'boundary.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);
}
+6 -5
View File
@@ -6,17 +6,18 @@
// final p = AppTheme.of(context); // final p = AppTheme.of(context);
// color: p.foreground // color: p.foreground
// //
// Pick the theme at build time: `--dart-define=YUKART_THEME=rei` (default: default). // Pick the theme at build time: `--dart-define=YUKART_THEME=default` (default: rei, smart-serow's).
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
const themeName = String.fromEnvironment( /// The HUD face (smart-serow's DIN 1451). Opt-in: a widget names it in its own
'YUKART_THEME', /// TextStyle; everything else keeps the basic font, which covers CJK where DIN does not.
defaultValue: 'default', const hudFont = 'DIN1451';
);
const themeName = String.fromEnvironment('YUKART_THEME', defaultValue: 'rei');
/// One variant (dark or bright) of a theme. /// One variant (dark or bright) of a theme.
class Palette { class Palette {
+3
View File
@@ -126,6 +126,7 @@ class _AccelGraphState extends State<AccelGraph> {
Text( Text(
'Lon: ${_formatAccel(widget.ay)} (${_formatAccel(_ghostAy)})', 'Lon: ${_formatAccel(widget.ay)} (${_formatAccel(_ghostAy)})',
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: fontSize * 0.5, fontSize: fontSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
@@ -136,6 +137,7 @@ class _AccelGraphState extends State<AccelGraph> {
Text( Text(
'Lat: ${_formatAccel(widget.ax)} (${_formatAccel(_ghostAx)})', 'Lat: ${_formatAccel(widget.ax)} (${_formatAccel(_ghostAx)})',
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: fontSize * 0.5, fontSize: fontSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
@@ -149,6 +151,7 @@ class _AccelGraphState extends State<AccelGraph> {
Text( Text(
'Acceleration', 'Acceleration',
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: fontSize * 0.8, fontSize: fontSize * 0.8,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: theme.subdued, color: theme.subdued,
+94
View File
@@ -0,0 +1,94 @@
// Ported from smart-serow (pi/ui/lib/widgets/gps_compass.dart), itself from android-stuff/gps-speedo:
// a heading arrow and its bearing. Changes: the palette import and lint fixes.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/palette.dart';
class GpsCompass extends StatelessWidget {
final double? heading;
final String? gpsState; // "acquiring", "fix", "lost"
const GpsCompass({super.key, this.heading, this.gpsState});
bool get _hasSignal => heading != null;
bool get _isAcquiring => gpsState == 'acquiring';
String get _displayHeading {
if (!_hasSignal) return 'N/A';
return '${(heading! % 360).round()}';
}
String get _compassDirection {
if (!_hasSignal) return '';
final directions = [
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW',
];
final index = ((heading! % 360) / 22.5).round() % 16;
return directions[index];
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
// No signal = subdued color, valid = foreground
final iconColour = _hasSignal ? theme.foreground : theme.highlight;
// Convert to radians, 0 = no rotation when no signal
final angle = _hasSignal ? (heading! * math.pi / 180.0) : 0.0;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 3,
child: Transform.rotate(
angle: _hasSignal ? angle : 0,
child: FittedBox(
fit: BoxFit.contain,
child: Icon(
_hasSignal ? Icons.navigation : Icons.navigation_outlined,
size: 120,
color: iconColour,
),
),
),
),
Flexible(
flex: 1,
child: FittedBox(
fit: BoxFit.contain,
child: Text(
_hasSignal
? "$_displayHeading $_compassDirection"
: (_isAcquiring ? "ACQ" : "N/A"),
style: TextStyle(
fontSize: 80,
color: theme.subdued, // less emphasis on text, let the icon have semantic colour
fontFamily: hudFont,
),
),
),
),
],
);
}
}
+22 -2
View File
@@ -50,7 +50,12 @@ class StatBox extends StatelessWidget {
builder: (context, constraints) { builder: (context, constraints) {
final baseSize = constraints.maxHeight * 0.4; final baseSize = constraints.maxHeight * 0.4;
return Column( // Shrinks (never wraps) when a label is wider than its box.
return Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Value + optional unit // Value + optional unit
@@ -62,6 +67,7 @@ class StatBox extends StatelessWidget {
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: baseSize, fontSize: baseSize,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
@@ -75,6 +81,7 @@ class StatBox extends StatelessWidget {
Text( Text(
unit!, unit!,
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: baseSize * 0.5, fontSize: baseSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: theme.subdued, color: theme.subdued,
@@ -89,6 +96,7 @@ class StatBox extends StatelessWidget {
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: baseSize * 0.6, fontSize: baseSize * 0.6,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: theme.subdued, color: theme.subdued,
@@ -96,6 +104,8 @@ class StatBox extends StatelessWidget {
), ),
), ),
], ],
),
),
); );
}, },
), ),
@@ -134,7 +144,12 @@ class StatBoxMain extends StatelessWidget {
// Scale fonts relative to box height for consistent proportions // Scale fonts relative to box height for consistent proportions
final baseSize = constraints.maxHeight * 0.4; final baseSize = constraints.maxHeight * 0.4;
return Column( // Shrinks (never wraps) when a label is wider than its box.
return Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Value + optional unit row // Value + optional unit row
@@ -146,6 +161,7 @@ class StatBoxMain extends StatelessWidget {
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: baseSize, fontSize: baseSize,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
@@ -162,6 +178,7 @@ class StatBoxMain extends StatelessWidget {
Text( Text(
unit!, unit!,
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: baseSize * 0.4, fontSize: baseSize * 0.4,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: theme.subdued, color: theme.subdued,
@@ -176,6 +193,7 @@ class StatBoxMain extends StatelessWidget {
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: baseSize * 0.35, fontSize: baseSize * 0.35,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: theme.subdued, color: theme.subdued,
@@ -183,6 +201,8 @@ class StatBoxMain extends StatelessWidget {
), ),
), ),
], ],
),
),
); );
}, },
), ),
+72
View File
@@ -0,0 +1,72 @@
// An on/off lamp in the boxed pattern (lib/widgets/stat_box.dart): the label is the
// lamp. Takes a [Reading] (lib/reading.dart) so missing and stale draw as they do elsewhere.
import 'package:flutter/material.dart';
import '../reading.dart';
import '../theme/palette.dart';
/// 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: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
missing ? '—' : label,
maxLines: 1,
style: TextStyle(
fontFamily: hudFont,
fontSize: size,
fontWeight: lit ? FontWeight.w700 : FontWeight.w400,
color: color,
letterSpacing: 1,
),
),
),
);
},
),
);
}
}
+3
View File
@@ -64,6 +64,7 @@ class WhiskeyMark extends StatelessWidget {
Text( Text(
'Roll: ${_formatAngle(roll)}', 'Roll: ${_formatAngle(roll)}',
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: fontSize * 0.5, fontSize: fontSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
@@ -74,6 +75,7 @@ class WhiskeyMark extends StatelessWidget {
Text( Text(
'Pitch: ${_formatAngle(pitch)}', 'Pitch: ${_formatAngle(pitch)}',
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: fontSize * 0.5, fontSize: fontSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
@@ -87,6 +89,7 @@ class WhiskeyMark extends StatelessWidget {
Text( Text(
'Attitude', 'Attitude',
style: TextStyle( style: TextStyle(
fontFamily: hudFont,
fontSize: fontSize * 0.8, fontSize: fontSize * 0.8,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: theme.subdued, color: theme.subdued,
+6
View File
@@ -57,6 +57,12 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# "Alte DIN 1451 Mittelschrift" (Peter Wiegel), the HUD face of smart-serow and gps-speedo.
fonts:
- family: DIN1451
fonts:
- asset: assets/fonts/din1451alt.ttf
# Exact copies of contracts files (tool/sync-contracts.sh); crosscheck verifies them. # Exact copies of contracts files (tool/sync-contracts.sh); crosscheck verifies them.
assets: assets:
- assets/contracts/checklists/ - assets/contracts/checklists/