From 7d8f813b5929e8ac14d5eae2d97af72331c28eea Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Thu, 5 Feb 2026 00:00:38 +0900 Subject: [PATCH] ui accelerometer compsensations and visual tweaks --- pi/ui/lib/app_root.dart | 18 +++++++++ pi/ui/lib/screens/dashboard_screen.dart | 26 ++++++++++++- pi/ui/lib/services/config_service.dart | 15 ++++++++ pi/ui/lib/widgets/accel_graph.dart | 50 ++++++++++++++++++++++--- pi/ui/lib/widgets/navigator_widget.dart | 6 +++ pi/ui/lib/widgets/whiskey_mark.dart | 2 +- 6 files changed, 108 insertions(+), 9 deletions(-) diff --git a/pi/ui/lib/app_root.dart b/pi/ui/lib/app_root.dart index 5a2795f..40745c5 100644 --- a/pi/ui/lib/app_root.dart +++ b/pi/ui/lib/app_root.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; @@ -42,8 +43,11 @@ class _AppRootState extends State { await Future.delayed(const Duration(milliseconds: 500)); // Check UART connection via backend health endpoint + // Also preload navigator images in parallel (usually UART is the bottleneck) setState(() => _initStatus = 'UART: connecting...'); + final imagePreloadFuture = _preloadNavigatorImages(); await _waitForUart(); + await imagePreloadFuture; setState(() => _initStatus = 'GPS: standby'); await Future.delayed(const Duration(milliseconds: 400)); @@ -97,6 +101,20 @@ class _AppRootState extends State { await Future.delayed(const Duration(milliseconds: 500)); } + /// Preload navigator images into Flutter's image cache + /// + /// Scans for all PNGs in the navigator folder and precaches them. + /// Runs silently - no status updates (meant to run parallel with UART). + Future _preloadNavigatorImages() async { + final images = await ConfigService.instance.getNavigatorImages(); + for (final file in images) { + // precacheImage needs a context, but we're in initState territory + // Use the root context via a post-frame callback workaround + if (!mounted) return; + await precacheImage(FileImage(file), context); + } + } + @override Widget build(BuildContext context) { // Determine which screen to show (priority: overheat > splash > dashboard) diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index 65653d6..9f55d3d 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' show sqrt, sin, cos, pi; import 'package:flutter/material.dart'; import '../services/backend_service.dart'; @@ -25,6 +26,8 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State { + static const _surpriseThreshold = 0.2; // G threshold for navigator surprise + final _navigatorKey = GlobalKey(); // Timer for Pi temp only (safety critical, direct file read) @@ -47,6 +50,8 @@ class _DashboardScreenState extends State { double? _pitch; double? _ax; double? _ay; + double? _dynamicAx; // Gravity-compensated + double? _dynamicAy; // From backend - GPS data double? _gpsSpeed; @@ -67,6 +72,16 @@ class _DashboardScreenState extends State { // Subscribe to Arduino data stream _arduinoSub = WebSocketService.instance.arduinoStream.listen((data) { + // Gravity-compensated acceleration + // When tilted, gravity "leaks" into horizontal axes - subtract it out + final rollRad = (data.roll ?? 0) * pi / 180; + final pitchRad = (data.pitch ?? 0) * pi / 180; + + // Subtract gravity leakage from measured acceleration + // Axes swapped for IMU mounting orientation + final dynamicAx = (data.ay ?? 0) + sin(rollRad); + final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad)); + setState(() { _voltage = data.voltage; _rpm = data.rpm; @@ -76,7 +91,14 @@ class _DashboardScreenState extends State { _pitch = data.pitch; _ax = data.ax; _ay = data.ay; + _dynamicAx = dynamicAx; + _dynamicAy = dynamicAy; }); + + final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy); + if (gMagnitude > _surpriseThreshold) { + _navigatorKey.currentState?.setEmotion('surprise'); + } }); // Subscribe to GPS data stream @@ -197,8 +219,8 @@ class _DashboardScreenState extends State { ), Expanded( child: AccelGraph( - ax: _ay, // Swapped: IMU Y → screen X (lateral) - ay: _ax, // Swapped: IMU X → screen Y (longitudinal) + ax: _dynamicAx, // Gravity-compensated lateral + ay: _dynamicAy, // Gravity-compensated longitudinal maxG: 1.0, ghostTrackPeriod: const Duration(seconds: 3), ), diff --git a/pi/ui/lib/services/config_service.dart b/pi/ui/lib/services/config_service.dart index 7186e42..84b2844 100644 --- a/pi/ui/lib/services/config_service.dart +++ b/pi/ui/lib/services/config_service.dart @@ -93,4 +93,19 @@ class ConfigService { if (value is String && value.isNotEmpty) return value; return 'http://127.0.0.1:5000'; } + + /// Get list of all navigator image files + /// + /// Scans the navigator directory for PNG files. + /// Returns empty list if directory doesn't exist. + Future> getNavigatorImages() async { + final dir = Directory('$assetsPath${Platform.pathSeparator}navigator${Platform.pathSeparator}$navigator'); + if (!await dir.exists()) return []; + + return dir + .listSync() + .whereType() + .where((f) => f.path.toLowerCase().endsWith('.png')) + .toList(); + } } diff --git a/pi/ui/lib/widgets/accel_graph.dart b/pi/ui/lib/widgets/accel_graph.dart index 54e7ff8..a273614 100644 --- a/pi/ui/lib/widgets/accel_graph.dart +++ b/pi/ui/lib/widgets/accel_graph.dart @@ -94,8 +94,9 @@ class _AccelGraphState extends State { return LayoutBuilder( builder: (context, constraints) { final size = math.min(constraints.maxWidth, constraints.maxHeight); - final gridSize = size * 0.75; + final gridSize = size * 0.6; final fontSize = size * 0.12; + final strokeSize = size * 0.015; return Column( mainAxisAlignment: MainAxisAlignment.center, @@ -115,12 +116,39 @@ class _AccelGraphState extends State { foreground: theme.foreground, subdued: theme.subdued, background: theme.background, + strokeWeight: strokeSize, ), ), ), 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( 'ACCEL', @@ -136,6 +164,11 @@ class _AccelGraphState extends State { }, ); } + + String _formatAccel(double? force) { + if (force == null) return '—°'; + return '${force.toStringAsFixed(1)}G'; + } } /// Custom painter for the G-meter grid and dots @@ -149,6 +182,7 @@ class _AccelGraphPainter extends CustomPainter { final Color foreground; final Color subdued; final Color background; + final double strokeWeight; _AccelGraphPainter({ required this.ax, @@ -160,19 +194,23 @@ class _AccelGraphPainter extends CustomPainter { required this.foreground, required this.subdued, required this.background, + required this.strokeWeight, }); @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.5G intervals final gridPaint = Paint() ..color = subdued - ..strokeWidth = 2 + ..strokeWidth = strokeWeight * 0.6 ..style = PaintingStyle.stroke; final gStep = 0.5; @@ -206,8 +244,8 @@ class _AccelGraphPainter extends CustomPainter { // Center axis lines (heavier) final axisPaint = Paint() - ..color = subdued.withValues(alpha: 0.6) - ..strokeWidth = 3 + ..color = subdued + ..strokeWidth = strokeWeight ..style = PaintingStyle.stroke; // Horizontal axis @@ -226,7 +264,7 @@ class _AccelGraphPainter extends CustomPainter { // G-ring markers (circles at 1G and 2G for quick reference) final ringPaint = Paint() ..color = subdued - ..strokeWidth = 1.5 + ..strokeWidth = strokeWeight ..style = PaintingStyle.stroke; for (double g = 1.0; g <= maxG; g += 1.0) { @@ -242,7 +280,7 @@ class _AccelGraphPainter extends CustomPainter { final ghostPaint = Paint() ..color = subdued.withValues(alpha: 0.5) - ..strokeWidth = 2 + ..strokeWidth = strokeWeight ..style = PaintingStyle.stroke; canvas.drawCircle(Offset(ghostX, ghostY), ghostRadius, ghostPaint); diff --git a/pi/ui/lib/widgets/navigator_widget.dart b/pi/ui/lib/widgets/navigator_widget.dart index 6d296fb..31644aa 100644 --- a/pi/ui/lib/widgets/navigator_widget.dart +++ b/pi/ui/lib/widgets/navigator_widget.dart @@ -31,6 +31,12 @@ class NavigatorWidgetState extends State duration: const Duration(milliseconds: 400), vsync: this, ); + // Auto-reset to default after surprise animation completes + _shakeController.addStatusListener((status) { + if (status == AnimationStatus.completed && _emotion == 'surprise') { + setState(() => _emotion = 'default'); + } + }); } @override diff --git a/pi/ui/lib/widgets/whiskey_mark.dart b/pi/ui/lib/widgets/whiskey_mark.dart index 1aadd08..5f92131 100644 --- a/pi/ui/lib/widgets/whiskey_mark.dart +++ b/pi/ui/lib/widgets/whiskey_mark.dart @@ -72,7 +72,7 @@ class WhiskeyMark extends StatelessWidget { ), SizedBox(width: size * 0.1), Text( - 'P: ${_formatAngle(pitch)}', + 'Pitch: ${_formatAngle(pitch)}', style: TextStyle( fontSize: fontSize * 0.5, fontWeight: FontWeight.w400,