ui accelerometer compsensations and visual tweaks
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user