pi ui accelerometer widget

This commit is contained in:
Mikkeli Matlock
2026-02-04 23:19:24 +09:00
parent 4a830dde91
commit 8044bbde94
6 changed files with 309 additions and 16 deletions

View File

@@ -8,7 +8,7 @@
"bright": { "bright": {
"background": "#fda052", "background": "#fda052",
"foreground": "#202020", "foreground": "#202020",
"highlight": "#FB2E0A", "highlight": "#df2100",
"subdued": "#EAEAEA" "subdued": "#EAEAEA"
} }
} }

View File

@@ -11,6 +11,7 @@ import '../widgets/stat_box_main.dart';
import '../widgets/system_bar.dart'; import '../widgets/system_bar.dart';
import '../widgets/debug_console.dart'; import '../widgets/debug_console.dart';
import '../widgets/whiskey_mark.dart'; import '../widgets/whiskey_mark.dart';
import '../widgets/accel_graph.dart';
// test service for triggers // test service for triggers
import '../services/test_flipflop_service.dart'; import '../services/test_flipflop_service.dart';
@@ -44,6 +45,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
int? _gear; int? _gear;
double? _roll; double? _roll;
double? _pitch; double? _pitch;
double? _ax;
double? _ay;
// From backend - GPS data // From backend - GPS data
double? _gpsSpeed; double? _gpsSpeed;
@@ -71,6 +74,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
_gear = data.gear; _gear = data.gear;
_roll = data.roll; _roll = data.roll;
_pitch = data.pitch; _pitch = data.pitch;
_ax = data.ax;
_ay = data.ay;
}); });
}); });
@@ -106,6 +111,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
_gear = cachedArduino.gear; _gear = cachedArduino.gear;
_roll = cachedArduino.roll; _roll = cachedArduino.roll;
_pitch = cachedArduino.pitch; _pitch = cachedArduino.pitch;
_ax = cachedArduino.ax;
_ay = cachedArduino.ay;
} }
final cachedGps = WebSocketService.instance.latestGps; final cachedGps = WebSocketService.instance.latestGps;
@@ -181,11 +188,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
flex: 8, flex: 8,
child: Row( child: Row(
children: [ children: [
// RPM from Arduino
// StatBoxMain(
// value: _formatInt(_rpm),
// label: 'RPM',
// ),
// Attitude indicator (whiskey mark) // Attitude indicator (whiskey mark)
Expanded( Expanded(
child: WhiskeyMark( child: WhiskeyMark(
@@ -193,6 +195,14 @@ class _DashboardScreenState extends State<DashboardScreen> {
pitch: _pitch, pitch: _pitch,
), ),
), ),
Expanded(
child: AccelGraph(
ax: _ay, // Swapped: IMU Y → screen X (lateral)
ay: _ax, // Swapped: IMU X → screen Y (longitudinal)
maxG: 1.0,
ghostTrackPeriod: const Duration(seconds: 3),
),
)
], ],
), ),
), ),
@@ -204,7 +214,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000), StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG', isWarning: () => (_engineTemp ?? 0) > 120),
StatBox(value: _formatGear(_gear), label: 'GEAR'), StatBox(value: _formatGear(_gear), label: 'GEAR'),
], ],
), ),

View File

@@ -10,8 +10,11 @@ class ArduinoData {
final int? gear; // 0 = neutral, 1-6 = gear final int? gear; // 0 = neutral, 1-6 = gear
final double? roll; // Euler angle in degrees (negative = left, positive = right) final double? roll; // Euler angle in degrees (negative = left, positive = right)
final double? pitch; // Euler angle in degrees (negative = nose down) final double? pitch; // Euler angle in degrees (negative = nose down)
final double? ax; // Lateral acceleration (g)
final double? ay; // Longitudinal acceleration (g)
final double? az; // Vertical acceleration (g)
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch}); ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
factory ArduinoData.fromJson(Map<String, dynamic> json) { factory ArduinoData.fromJson(Map<String, dynamic> json) {
return ArduinoData( return ArduinoData(
@@ -21,6 +24,9 @@ class ArduinoData {
gear: (json['gear'] as num?)?.toInt(), gear: (json['gear'] as num?)?.toInt(),
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
pitch: (json['pitch'] as num?)?.toDouble(), pitch: (json['pitch'] as num?)?.toDouble(),
ax: (json['ax'] as num?)?.toDouble(),
ay: (json['ay'] as num?)?.toDouble(),
az: (json['az'] as num?)?.toDouble(),
); );
} }
} }

View File

@@ -33,11 +33,11 @@ class TestFlipFlopService {
// ThemeService.instance.toggle(); // ThemeService.instance.toggle();
// Surprise the navigator // Surprise the navigator
if (navigatorKey.currentState?.emotion == 'surprise') { // if (navigatorKey.currentState?.emotion == 'surprise') {
navigatorKey.currentState?.reset(); // navigatorKey.currentState?.reset();
} else { // } else {
navigatorKey.currentState?.setEmotion('surprise'); // navigatorKey.currentState?.setEmotion('surprise');
} // }
}); });
} }

View File

@@ -0,0 +1,276 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/app_theme.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;
Timer? _ghostResetTimer;
@override
void initState() {
super.initState();
_setupGhostTimer();
}
@override
void didUpdateWidget(AccelGraph oldWidget) {
super.didUpdateWidget(oldWidget);
// Update ghost position if current magnitude exceeds previous peak
final currentAx = widget.ax ?? 0;
final currentAy = widget.ay ?? 0;
final currentMag = math.sqrt(currentAx * currentAx + currentAy * currentAy);
if (currentMag > _ghostMagnitude) {
_ghostAx = currentAx;
_ghostAy = currentAy;
_ghostMagnitude = currentMag;
}
// Restart timer if period changed
if (oldWidget.ghostTrackPeriod != widget.ghostTrackPeriod) {
_setupGhostTimer();
}
}
void _setupGhostTimer() {
_ghostResetTimer?.cancel();
if (widget.ghostTrackPeriod != null) {
_ghostResetTimer = Timer.periodic(widget.ghostTrackPeriod!, (_) {
setState(() {
// Reset ghost to current position
_ghostAx = widget.ax ?? 0;
_ghostAy = widget.ay ?? 0;
_ghostMagnitude = math.sqrt(_ghostAx * _ghostAx + _ghostAy * _ghostAy);
});
});
}
}
@override
void dispose() {
_ghostResetTimer?.cancel();
super.dispose();
}
@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.75;
final fontSize = size * 0.12;
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,
),
),
),
SizedBox(height: size * 0.03),
// Label
Text(
'ACCEL',
style: TextStyle(
fontSize: fontSize * 0.8,
fontWeight: FontWeight.w400,
color: theme.subdued,
letterSpacing: 1,
),
),
],
);
},
);
}
}
/// 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;
_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,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final halfSize = size.width / 2;
// No rectangular border
// Grid lines at 0.5G intervals
final gridPaint = Paint()
..color = subdued
..strokeWidth = 2
..style = PaintingStyle.stroke;
final gStep = 0.5;
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.withValues(alpha: 0.6)
..strokeWidth = 3
..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 1G and 2G for quick reference)
final ringPaint = Paint()
..color = subdued
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
for (double g = 1.0; g <= maxG; g += 1.0) {
final radius = (g / maxG) * halfSize;
canvas.drawCircle(center, radius, ringPaint);
}
// 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.withValues(alpha: 0.5)
..strokeWidth = 2
..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;
}
}

View File

@@ -64,7 +64,7 @@ class WhiskeyMark extends StatelessWidget {
Text( Text(
'Roll: ${_formatAngle(roll)}', 'Roll: ${_formatAngle(roll)}',
style: TextStyle( style: TextStyle(
fontSize: fontSize * 0.8, fontSize: fontSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
color: theme.foreground, color: theme.foreground,
@@ -74,7 +74,7 @@ class WhiskeyMark extends StatelessWidget {
Text( Text(
'P: ${_formatAngle(pitch)}', 'P: ${_formatAngle(pitch)}',
style: TextStyle( style: TextStyle(
fontSize: fontSize * 0.8, fontSize: fontSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
color: theme.subdued, color: theme.subdued,
@@ -101,7 +101,9 @@ class WhiskeyMark extends StatelessWidget {
String _formatAngle(double? angle) { String _formatAngle(double? angle) {
if (angle == null) return '—°'; if (angle == null) return '—°';
return '${angle.round()}°'; return '${
angle.round() > 180 ? angle.round() - 360 : angle.round()
}°';
} }
} }