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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user