ui accelerometer compsensations and visual tweaks

This commit is contained in:
Mikkeli Matlock
2026-02-05 00:00:38 +09:00
parent 8044bbde94
commit 7d8f813b59
6 changed files with 108 additions and 9 deletions

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -42,8 +43,11 @@ class _AppRootState extends State<AppRoot> {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
// Check UART connection via backend health endpoint // Check UART connection via backend health endpoint
// Also preload navigator images in parallel (usually UART is the bottleneck)
setState(() => _initStatus = 'UART: connecting...'); setState(() => _initStatus = 'UART: connecting...');
final imagePreloadFuture = _preloadNavigatorImages();
await _waitForUart(); await _waitForUart();
await imagePreloadFuture;
setState(() => _initStatus = 'GPS: standby'); setState(() => _initStatus = 'GPS: standby');
await Future.delayed(const Duration(milliseconds: 400)); await Future.delayed(const Duration(milliseconds: 400));
@@ -97,6 +101,20 @@ class _AppRootState extends State<AppRoot> {
await Future.delayed(const Duration(milliseconds: 500)); 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Determine which screen to show (priority: overheat > splash > dashboard) // Determine which screen to show (priority: overheat > splash > dashboard)

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' show sqrt, sin, cos, pi;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/backend_service.dart'; import '../services/backend_service.dart';
@@ -25,6 +26,8 @@ class DashboardScreen extends StatefulWidget {
} }
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> {
static const _surpriseThreshold = 0.2; // G threshold for navigator surprise
final _navigatorKey = GlobalKey<NavigatorWidgetState>(); final _navigatorKey = GlobalKey<NavigatorWidgetState>();
// Timer for Pi temp only (safety critical, direct file read) // Timer for Pi temp only (safety critical, direct file read)
@@ -47,6 +50,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
double? _pitch; double? _pitch;
double? _ax; double? _ax;
double? _ay; double? _ay;
double? _dynamicAx; // Gravity-compensated
double? _dynamicAy;
// From backend - GPS data // From backend - GPS data
double? _gpsSpeed; double? _gpsSpeed;
@@ -67,6 +72,16 @@ class _DashboardScreenState extends State<DashboardScreen> {
// Subscribe to Arduino data stream // Subscribe to Arduino data stream
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) { _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(() { setState(() {
_voltage = data.voltage; _voltage = data.voltage;
_rpm = data.rpm; _rpm = data.rpm;
@@ -76,7 +91,14 @@ class _DashboardScreenState extends State<DashboardScreen> {
_pitch = data.pitch; _pitch = data.pitch;
_ax = data.ax; _ax = data.ax;
_ay = data.ay; _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 // Subscribe to GPS data stream
@@ -197,8 +219,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
Expanded( Expanded(
child: AccelGraph( child: AccelGraph(
ax: _ay, // Swapped: IMU Y → screen X (lateral) ax: _dynamicAx, // Gravity-compensated lateral
ay: _ax, // Swapped: IMU X → screen Y (longitudinal) ay: _dynamicAy, // Gravity-compensated longitudinal
maxG: 1.0, maxG: 1.0,
ghostTrackPeriod: const Duration(seconds: 3), ghostTrackPeriod: const Duration(seconds: 3),
), ),

View File

@@ -93,4 +93,19 @@ class ConfigService {
if (value is String && value.isNotEmpty) return value; if (value is String && value.isNotEmpty) return value;
return 'http://127.0.0.1:5000'; 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<List<File>> getNavigatorImages() async {
final dir = Directory('$assetsPath${Platform.pathSeparator}navigator${Platform.pathSeparator}$navigator');
if (!await dir.exists()) return [];
return dir
.listSync()
.whereType<File>()
.where((f) => f.path.toLowerCase().endsWith('.png'))
.toList();
}
} }

View File

@@ -94,8 +94,9 @@ class _AccelGraphState extends State<AccelGraph> {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final size = math.min(constraints.maxWidth, constraints.maxHeight); final size = math.min(constraints.maxWidth, constraints.maxHeight);
final gridSize = size * 0.75; final gridSize = size * 0.6;
final fontSize = size * 0.12; final fontSize = size * 0.12;
final strokeSize = size * 0.015;
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -115,12 +116,39 @@ class _AccelGraphState extends State<AccelGraph> {
foreground: theme.foreground, foreground: theme.foreground,
subdued: theme.subdued, subdued: theme.subdued,
background: theme.background, background: theme.background,
strokeWeight: strokeSize,
), ),
), ),
), ),
SizedBox(height: size * 0.03), 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 // Label
Text( Text(
'ACCEL', 'ACCEL',
@@ -136,6 +164,11 @@ class _AccelGraphState extends State<AccelGraph> {
}, },
); );
} }
String _formatAccel(double? force) {
if (force == null) return '—°';
return '${force.toStringAsFixed(1)}G';
}
} }
/// Custom painter for the G-meter grid and dots /// Custom painter for the G-meter grid and dots
@@ -149,6 +182,7 @@ class _AccelGraphPainter extends CustomPainter {
final Color foreground; final Color foreground;
final Color subdued; final Color subdued;
final Color background; final Color background;
final double strokeWeight;
_AccelGraphPainter({ _AccelGraphPainter({
required this.ax, required this.ax,
@@ -160,19 +194,23 @@ class _AccelGraphPainter extends CustomPainter {
required this.foreground, required this.foreground,
required this.subdued, required this.subdued,
required this.background, required this.background,
required this.strokeWeight,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2); final center = Offset(size.width / 2, size.height / 2);
final halfSize = size.width / 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 // No rectangular border
// Grid lines at 0.5G intervals // Grid lines at 0.5G intervals
final gridPaint = Paint() final gridPaint = Paint()
..color = subdued ..color = subdued
..strokeWidth = 2 ..strokeWidth = strokeWeight * 0.6
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
final gStep = 0.5; final gStep = 0.5;
@@ -206,8 +244,8 @@ class _AccelGraphPainter extends CustomPainter {
// Center axis lines (heavier) // Center axis lines (heavier)
final axisPaint = Paint() final axisPaint = Paint()
..color = subdued.withValues(alpha: 0.6) ..color = subdued
..strokeWidth = 3 ..strokeWidth = strokeWeight
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
// Horizontal axis // Horizontal axis
@@ -226,7 +264,7 @@ class _AccelGraphPainter extends CustomPainter {
// G-ring markers (circles at 1G and 2G for quick reference) // G-ring markers (circles at 1G and 2G for quick reference)
final ringPaint = Paint() final ringPaint = Paint()
..color = subdued ..color = subdued
..strokeWidth = 1.5 ..strokeWidth = strokeWeight
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
for (double g = 1.0; g <= maxG; g += 1.0) { for (double g = 1.0; g <= maxG; g += 1.0) {
@@ -242,7 +280,7 @@ class _AccelGraphPainter extends CustomPainter {
final ghostPaint = Paint() final ghostPaint = Paint()
..color = subdued.withValues(alpha: 0.5) ..color = subdued.withValues(alpha: 0.5)
..strokeWidth = 2 ..strokeWidth = strokeWeight
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
canvas.drawCircle(Offset(ghostX, ghostY), ghostRadius, ghostPaint); canvas.drawCircle(Offset(ghostX, ghostY), ghostRadius, ghostPaint);

View File

@@ -31,6 +31,12 @@ class NavigatorWidgetState extends State<NavigatorWidget>
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
vsync: this, vsync: this,
); );
// Auto-reset to default after surprise animation completes
_shakeController.addStatusListener((status) {
if (status == AnimationStatus.completed && _emotion == 'surprise') {
setState(() => _emotion = 'default');
}
});
} }
@override @override

View File

@@ -72,7 +72,7 @@ class WhiskeyMark extends StatelessWidget {
), ),
SizedBox(width: size * 0.1), SizedBox(width: size * 0.1),
Text( Text(
'P: ${_formatAngle(pitch)}', 'Pitch: ${_formatAngle(pitch)}',
style: TextStyle( style: TextStyle(
fontSize: fontSize * 0.5, fontSize: fontSize * 0.5,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,