MotorSignal-App/lib/features/settings/diagnostics_screen.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,
),
),
),
],
),
);
}
}