diff --git a/assets/fonts/din1451alt.ttf b/assets/fonts/din1451alt.ttf new file mode 100644 index 0000000..aef931a Binary files /dev/null and b/assets/fonts/din1451alt.ttf differ diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..deb246a --- /dev/null +++ b/lib/README.md @@ -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. diff --git a/lib/dash.dart b/lib/dash.dart index 34f66da..f5acf26 100644 --- a/lib/dash.dart +++ b/lib/dash.dart @@ -6,35 +6,14 @@ import 'package:flutter/material.dart'; import 'boundary.dart'; +import 'reading.dart'; import 'theme/palette.dart'; import 'widgets/accel_graph.dart'; +import 'widgets/gps_compass.dart'; import 'widgets/stat_box.dart'; +import 'widgets/telltale.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; @@ -54,6 +33,13 @@ class DashPage extends StatelessWidget { final coolant = r('obd_coolant_temp_c'); final lat = r('imu_accel_lat_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( backgroundColor: p.background, @@ -139,6 +125,12 @@ class DashPage extends StatelessWidget { dim: coolant.dim, isWarning: () => (coolant.number ?? 0) > 105, ), + Expanded( + child: GpsCompass( + heading: r('gnss_heading_deg').number, + gpsState: gpsState, + ), + ), Telltale( label: '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. class _DashBar extends StatelessWidget { const _DashBar({required this.boundary, required this.themes}); diff --git a/lib/reading.dart b/lib/reading.dart new file mode 100644 index 0000000..47c7cd2 --- /dev/null +++ b/lib/reading.dart @@ -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); +} diff --git a/lib/theme/palette.dart b/lib/theme/palette.dart index 9623072..638226a 100644 --- a/lib/theme/palette.dart +++ b/lib/theme/palette.dart @@ -6,17 +6,18 @@ // final p = AppTheme.of(context); // 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 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -const themeName = String.fromEnvironment( - 'YUKART_THEME', - defaultValue: 'default', -); +/// The HUD face (smart-serow's DIN 1451). Opt-in: a widget names it in its own +/// TextStyle; everything else keeps the basic font, which covers CJK where DIN does not. +const hudFont = 'DIN1451'; + +const themeName = String.fromEnvironment('YUKART_THEME', defaultValue: 'rei'); /// One variant (dark or bright) of a theme. class Palette { diff --git a/lib/widgets/accel_graph.dart b/lib/widgets/accel_graph.dart index f740b56..c0cf342 100644 --- a/lib/widgets/accel_graph.dart +++ b/lib/widgets/accel_graph.dart @@ -126,6 +126,7 @@ class _AccelGraphState extends State { Text( 'Lon: ${_formatAccel(widget.ay)} (${_formatAccel(_ghostAy)})', style: TextStyle( + fontFamily: hudFont, fontSize: fontSize * 0.5, fontWeight: FontWeight.w400, fontFeatures: const [FontFeature.tabularFigures()], @@ -136,6 +137,7 @@ class _AccelGraphState extends State { Text( 'Lat: ${_formatAccel(widget.ax)} (${_formatAccel(_ghostAx)})', style: TextStyle( + fontFamily: hudFont, fontSize: fontSize * 0.5, fontWeight: FontWeight.w400, fontFeatures: const [FontFeature.tabularFigures()], @@ -149,6 +151,7 @@ class _AccelGraphState extends State { Text( 'Acceleration', style: TextStyle( + fontFamily: hudFont, fontSize: fontSize * 0.8, fontWeight: FontWeight.w400, color: theme.subdued, diff --git a/lib/widgets/gps_compass.dart b/lib/widgets/gps_compass.dart new file mode 100644 index 0000000..3259944 --- /dev/null +++ b/lib/widgets/gps_compass.dart @@ -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, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/stat_box.dart b/lib/widgets/stat_box.dart index f1dd4ac..aaad93e 100644 --- a/lib/widgets/stat_box.dart +++ b/lib/widgets/stat_box.dart @@ -50,52 +50,62 @@ class StatBox extends StatelessWidget { builder: (context, constraints) { final baseSize = constraints.maxHeight * 0.4; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Value + optional unit - Row( + // 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, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, children: [ + // Value + optional unit + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + value, + style: TextStyle( + fontFamily: hudFont, + 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( + fontFamily: hudFont, + fontSize: baseSize * 0.5, + fontWeight: FontWeight.w400, + color: theme.subdued, + height: 1, + ), + ), + ], + ], + ), + SizedBox(height: baseSize * 0.1), + // Label Text( - value, + label, style: TextStyle( - fontSize: baseSize, + fontFamily: hudFont, + fontSize: baseSize * 0.6, fontWeight: FontWeight.w400, - fontFeatures: const [FontFeature.tabularFigures()], - color: valueColor, - height: 1, + color: theme.subdued, + letterSpacing: 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, - ), - ), - ], + ), ); }, ), @@ -134,55 +144,65 @@ class StatBoxMain extends StatelessWidget { // 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( + // 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, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, children: [ + // Value + optional unit row + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + value, + style: TextStyle( + fontFamily: hudFont, + 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( + fontFamily: hudFont, + fontSize: baseSize * 0.4, + fontWeight: FontWeight.w400, + color: theme.subdued, + height: 1, + ), + ), + ], + ], + ), + SizedBox(height: baseSize * 0.1), + // Label Text( - value, + label, style: TextStyle( - fontSize: baseSize, - fontWeight: FontWeight.w300, - fontFeatures: const [FontFeature.tabularFigures()], - color: dim - ? theme.subdued - : warning - ? theme.highlight - : theme.foreground, - height: 1, + fontFamily: hudFont, + fontSize: baseSize * 0.35, + fontWeight: FontWeight.w400, + color: theme.subdued, + letterSpacing: 2, ), ), - 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/telltale.dart b/lib/widgets/telltale.dart new file mode 100644 index 0000000..6c71816 --- /dev/null +++ b/lib/widgets/telltale.dart @@ -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, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/whiskey_mark.dart b/lib/widgets/whiskey_mark.dart index f5ffeaf..c7f7ebe 100644 --- a/lib/widgets/whiskey_mark.dart +++ b/lib/widgets/whiskey_mark.dart @@ -64,6 +64,7 @@ class WhiskeyMark extends StatelessWidget { Text( 'Roll: ${_formatAngle(roll)}', style: TextStyle( + fontFamily: hudFont, fontSize: fontSize * 0.5, fontWeight: FontWeight.w400, fontFeatures: const [FontFeature.tabularFigures()], @@ -74,6 +75,7 @@ class WhiskeyMark extends StatelessWidget { Text( 'Pitch: ${_formatAngle(pitch)}', style: TextStyle( + fontFamily: hudFont, fontSize: fontSize * 0.5, fontWeight: FontWeight.w400, fontFeatures: const [FontFeature.tabularFigures()], @@ -87,6 +89,7 @@ class WhiskeyMark extends StatelessWidget { Text( 'Attitude', style: TextStyle( + fontFamily: hudFont, fontSize: fontSize * 0.8, fontWeight: FontWeight.w400, color: theme.subdued, diff --git a/pubspec.yaml b/pubspec.yaml index 9820442..df9229d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,12 @@ flutter: # the material Icons class. 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. assets: - assets/contracts/checklists/