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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"dark": {
|
||||||
|
"background": "#000000",
|
||||||
|
"foreground": "#FFFFFF",
|
||||||
|
"highlight": "#FF5555",
|
||||||
|
"subdued": "#808080"
|
||||||
|
},
|
||||||
|
"bright": {
|
||||||
|
"background": "#F0F0F0",
|
||||||
|
"foreground": "#1A1A1A",
|
||||||
|
"highlight": "#CC0000",
|
||||||
|
"subdued": "#606060"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"dark": {
|
||||||
|
"background": "#303030",
|
||||||
|
"foreground": "#EAEAEA",
|
||||||
|
"highlight": "#FA1504",
|
||||||
|
"subdued": "#fda052"
|
||||||
|
},
|
||||||
|
"bright": {
|
||||||
|
"background": "#fda052",
|
||||||
|
"foreground": "#303030",
|
||||||
|
"highlight": "#df2100",
|
||||||
|
"subdued": "#EAEAEA"
|
||||||
|
}
|
||||||
|
}
|
||||||
+266
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-13
@@ -8,8 +8,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'boundary.dart';
|
import 'boundary.dart';
|
||||||
import 'camview.dart';
|
import 'camview.dart';
|
||||||
import 'checklist.dart';
|
import 'checklist.dart';
|
||||||
|
import 'dash.dart';
|
||||||
import 'linktest.dart';
|
import 'linktest.dart';
|
||||||
import 'net.dart';
|
import 'net.dart';
|
||||||
|
import 'theme/palette.dart';
|
||||||
|
|
||||||
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
|
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
|
||||||
const defaultUrl = String.fromEnvironment(
|
const defaultUrl = String.fromEnvironment(
|
||||||
@@ -19,6 +21,7 @@ const defaultUrl = String.fromEnvironment(
|
|||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
themes = await Themes.load();
|
||||||
final usb = UsbLink();
|
final usb = UsbLink();
|
||||||
await usb.bindFor(Uri.parse(defaultUrl));
|
await usb.bindFor(Uri.parse(defaultUrl));
|
||||||
final boundary = Boundary(defaultUrl)..start();
|
final boundary = Boundary(defaultUrl)..start();
|
||||||
@@ -58,6 +61,9 @@ Future<void> main() async {
|
|||||||
|
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
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(
|
Widget linkTestPage(Boundary b, LinkTest t, UsbLink usb) => LinkTestPage(
|
||||||
test: t,
|
test: t,
|
||||||
host: Uri.parse(b.url).host,
|
host: Uri.parse(b.url).host,
|
||||||
@@ -77,19 +83,21 @@ class YukartApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
// Every page takes its colours from the palette; switching dark/bright rebuilds them all.
|
||||||
title: 'Yukart',
|
return ListenableBuilder(
|
||||||
navigatorKey: navigatorKey,
|
listenable: themes,
|
||||||
debugShowCheckedModeBanner: false,
|
builder: (context, _) => MaterialApp(
|
||||||
theme: ThemeData(
|
title: 'Yukart',
|
||||||
colorSchemeSeed: Colors.teal,
|
navigatorKey: navigatorKey,
|
||||||
brightness: Brightness.dark,
|
debugShowCheckedModeBanner: false,
|
||||||
useMaterial3: true,
|
theme: themes.material(),
|
||||||
),
|
builder: (context, child) =>
|
||||||
home: ListenableBuilder(
|
AppTheme(palette: themes.current, child: child!),
|
||||||
listenable: Listenable.merge([boundary, usb]),
|
home: ListenableBuilder(
|
||||||
builder: (context, _) =>
|
listenable: Listenable.merge([boundary, usb]),
|
||||||
Readout(boundary: boundary, usb: usb, linkTest: linkTest),
|
builder: (context, _) =>
|
||||||
|
Readout(boundary: boundary, usb: usb, linkTest: linkTest),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,6 +241,15 @@ class LinkBar extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'USB link ${usb.up ? 'up' : 'absent'}${usb.bound ? ', bound' : ''}',
|
'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(
|
TextButton.icon(
|
||||||
onPressed: () => Navigator.of(context).push(
|
onPressed: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ flutter:
|
|||||||
# 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/
|
||||||
|
# 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
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|||||||
Reference in New Issue
Block a user