578 lines
19 KiB
Dart
578 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/responsive/viewport_size.dart';
|
|
import '../../core/theme/theme_extensions.dart';
|
|
import '../../services/telemetry_service.dart';
|
|
import '../../services/sensor_fusion_service.dart';
|
|
|
|
/// Canonical responsive diagnostics screen displaying sensor health, GNSS fidelity, and raw IMU readings.
|
|
class DiagnosticsScreen extends ConsumerWidget {
|
|
const DiagnosticsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final snapshot = context.responsive;
|
|
final isWide = snapshot.isExpanded || (snapshot.isMedium && snapshot.isLandscape);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text(
|
|
'Sensor Diagnostics',
|
|
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCompactLayout(BuildContext context) {
|
|
return const SingleChildScrollView(
|
|
padding: EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_TimestampHeaderCard(),
|
|
SizedBox(height: 12),
|
|
_SensorHealthCard(),
|
|
SizedBox(height: 12),
|
|
_GpsDataCard(),
|
|
SizedBox(height: 12),
|
|
_ImuGForceCard(),
|
|
SizedBox(height: 16),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWideLayout(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 1200),
|
|
child: const Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_TimestampHeaderCard(),
|
|
SizedBox(height: 16),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
_SensorHealthCard(),
|
|
SizedBox(height: 16),
|
|
_GpsDataCard(),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 16),
|
|
Expanded(
|
|
child: _ImuGForceCard(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Header card displaying live sample timestamp isolated to prevent full-screen redraws.
|
|
class _TimestampHeaderCard extends ConsumerWidget {
|
|
const _TimestampHeaderCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colors = context.motorColors;
|
|
final timestamp = ref.watch(
|
|
telemetryStreamProvider.select((async) => async.value?.timestamp),
|
|
);
|
|
|
|
return Material(
|
|
color: colors.surfaceContainer,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.access_time_rounded, color: colors.cyberCyan, size: 16),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
'SAMPLE TIME (UTC)',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.5,
|
|
color: colors.cyberCyan,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
timestamp != null
|
|
? timestamp.toUtc().toIso8601String().substring(11, 23)
|
|
: 'Awaiting samples...',
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: timestamp != null
|
|
? Theme.of(context).colorScheme.onSurface
|
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Diagnostic health indicator card checking GPS, IMU, and heading signals truthfully.
|
|
class _SensorHealthCard extends ConsumerWidget {
|
|
const _SensorHealthCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colors = context.motorColors;
|
|
final telemetry = ref.watch(telemetryStreamProvider).value;
|
|
|
|
final gpsAccuracy = telemetry?.gpsAccuracy;
|
|
String gpsHealthText = 'No Fix (Unavailable)';
|
|
Color gpsHealthColor = colors.raceRed;
|
|
IconData gpsIcon = Icons.cancel_outlined;
|
|
|
|
if (gpsAccuracy != null) {
|
|
if (gpsAccuracy <= 15.0) {
|
|
gpsHealthText = 'Healthy (±${gpsAccuracy.toStringAsFixed(1)}m)';
|
|
gpsHealthColor = colors.launchGreen;
|
|
gpsIcon = Icons.check_circle_outline;
|
|
} else if (gpsAccuracy <= 35.0) {
|
|
gpsHealthText = 'Degraded (±${gpsAccuracy.toStringAsFixed(1)}m)';
|
|
gpsHealthColor = colors.warningAmber;
|
|
gpsIcon = Icons.warning_amber_rounded;
|
|
} else {
|
|
gpsHealthText = 'Poor (±${gpsAccuracy.toStringAsFixed(1)}m)';
|
|
gpsHealthColor = colors.raceRed;
|
|
gpsIcon = Icons.error_outline;
|
|
}
|
|
}
|
|
|
|
final hasImu = telemetry?.accelX != null || telemetry?.accelY != null || telemetry?.accelZ != null;
|
|
final hasHeading = telemetry?.heading != null;
|
|
|
|
return Material(
|
|
color: colors.surfaceContainer,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.monitor_heart, color: colors.cyberCyan, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'SENSOR HEALTH & SIGNAL',
|
|
style: TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.8,
|
|
color: colors.cyberCyan,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildHealthItem(
|
|
context,
|
|
label: 'GPS Constellation Fix',
|
|
statusText: gpsHealthText,
|
|
statusColor: gpsHealthColor,
|
|
icon: gpsIcon,
|
|
),
|
|
const Divider(height: 14),
|
|
_buildHealthItem(
|
|
context,
|
|
label: 'IMU / Accel (~50Hz)',
|
|
statusText: hasImu ? 'Active & Streaming' : 'No Data / Unavailable',
|
|
statusColor: hasImu ? colors.launchGreen : colors.outlineVariant,
|
|
icon: hasImu ? Icons.check_circle_outline : Icons.remove_circle_outline,
|
|
),
|
|
const Divider(height: 14),
|
|
_buildHealthItem(
|
|
context,
|
|
label: 'Compass / Gyro Fusion',
|
|
statusText: hasHeading ? 'Active & Calibrated' : 'No Data / Unavailable',
|
|
statusColor: hasHeading ? colors.launchGreen : colors.outlineVariant,
|
|
icon: hasHeading ? Icons.check_circle_outline : Icons.remove_circle_outline,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHealthItem(
|
|
BuildContext context, {
|
|
required String label,
|
|
required String statusText,
|
|
required Color statusColor,
|
|
required IconData icon,
|
|
}) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
Icon(icon, color: statusColor, size: 14),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
statusText,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: statusColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// GPS Position & Navigation metrics card.
|
|
class _GpsDataCard extends ConsumerWidget {
|
|
const _GpsDataCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colors = context.motorColors;
|
|
final telemetry = ref.watch(telemetryStreamProvider).value;
|
|
|
|
return Material(
|
|
color: colors.surfaceContainer,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.gps_fixed, color: colors.cyberCyan, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'GPS & NAVIGATION METRICS',
|
|
style: TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.8,
|
|
color: colors.cyberCyan,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'GPS Accuracy',
|
|
value: telemetry?.gpsAccuracy != null
|
|
? '±${telemetry!.gpsAccuracy!.toStringAsFixed(1)} m'
|
|
: 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Latitude',
|
|
value: telemetry?.latitude?.toStringAsFixed(6) ?? 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Longitude',
|
|
value: telemetry?.longitude?.toStringAsFixed(6) ?? 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Altitude (MSL)',
|
|
value: telemetry?.altitude != null
|
|
? '${telemetry!.altitude!.toStringAsFixed(1)} m'
|
|
: 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Ground Speed',
|
|
value: telemetry?.speed != null
|
|
? '${telemetry!.speed!.toStringAsFixed(1)} MPH'
|
|
: 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Heading',
|
|
value: telemetry?.heading != null
|
|
? '${telemetry!.heading!.toStringAsFixed(1)}°'
|
|
: 'N/A',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricRow(BuildContext context, {required String label, required String value}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3.5),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
value,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// IMU, G-Force, and Derived Orientation card.
|
|
class _ImuGForceCard extends ConsumerWidget {
|
|
const _ImuGForceCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colors = context.motorColors;
|
|
final telemetry = ref.watch(telemetryStreamProvider).value;
|
|
final diag = ref.watch(sensorDiagnosticsStreamProvider).value;
|
|
|
|
return Material(
|
|
color: colors.surfaceContainer,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.screen_rotation, color: colors.cyberCyan, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'IMU, G-FORCE & ORIENTATION',
|
|
style: TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.8,
|
|
color: colors.cyberCyan,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
() {
|
|
final accelX = telemetry?.accelX;
|
|
final accelY = telemetry?.accelY;
|
|
final accelZ = telemetry?.accelZ;
|
|
final longG = telemetry?.longitudinalG;
|
|
final latG = telemetry?.lateralG;
|
|
final lean = telemetry?.leanAngle;
|
|
final pitch = telemetry?.pitchAngle;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Mount Orientation',
|
|
value: diag?.mountOrientation ?? 'Portrait Upright',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Mount Calibration',
|
|
value: (diag?.isCalibrated ?? false) ? 'CALIBRATED' : 'UNCALIBRATED',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Calibration Offsets (R / P)',
|
|
value: diag != null
|
|
? '${diag.rollOffset.toStringAsFixed(1)}° / ${diag.pitchOffset.toStringAsFixed(1)}°'
|
|
: '0.0° / 0.0°',
|
|
),
|
|
const Divider(height: 12),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Raw Accel (X, Y, Z)',
|
|
value: diag != null
|
|
? '${diag.rawAccelX.toStringAsFixed(2)}, ${diag.rawAccelY.toStringAsFixed(2)}, ${diag.rawAccelZ.toStringAsFixed(2)} m/s²'
|
|
: 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Raw Gyro (X, Y, Z)',
|
|
value: diag != null
|
|
? '${diag.rawGyroX.toStringAsFixed(3)}, ${diag.rawGyroY.toStringAsFixed(3)}, ${diag.rawGyroZ.toStringAsFixed(3)} rad/s'
|
|
: 'N/A',
|
|
),
|
|
const Divider(height: 12),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Transformed Roll / Pitch',
|
|
value: diag != null
|
|
? '${diag.transformedRoll.toStringAsFixed(1)}° / ${diag.transformedPitch.toStringAsFixed(1)}°'
|
|
: 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Dashboard Lean Angle',
|
|
value: lean != null
|
|
? '${lean.abs().toStringAsFixed(1)}° (${lean < 0 ? "L" : (lean > 0 ? "R" : "Center")})'
|
|
: '--° (UNCALIBRATED)',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Linear Accel X (Lat)',
|
|
value: accelX != null ? '${accelX.toStringAsFixed(2)} m/s²' : 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Linear Accel Y (Long)',
|
|
value: accelY != null ? '${accelY.toStringAsFixed(2)} m/s²' : 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Linear Accel Z (Vert)',
|
|
value: accelZ != null ? '${accelZ.toStringAsFixed(2)} m/s²' : 'N/A',
|
|
),
|
|
const Divider(height: 12),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Longitudinal G-Force',
|
|
value: longG != null ? '${longG.toStringAsFixed(2)} G' : 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Lateral G-Force',
|
|
value: latG != null ? '${latG.toStringAsFixed(2)} G' : 'N/A',
|
|
),
|
|
_buildMetricRow(
|
|
context,
|
|
label: 'Derived Pitch Angle',
|
|
value: pitch != null ? '${pitch.toStringAsFixed(1)}°' : 'N/A',
|
|
),
|
|
],
|
|
);
|
|
}(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricRow(BuildContext context, {required String label, required String value}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3.5),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
value,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|