feat: unify diagnostics and field test experience
This commit is contained in:
parent
e6e870c991
commit
5c61a2b749
|
|
@ -1,14 +1,15 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/responsive/viewport_size.dart';
|
||||
import '../../core/theme/theme_extensions.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../services/location_service.dart';
|
||||
import '../../services/field_test/field_test_logger.dart';
|
||||
import '../rides/ride_state_machine.dart';
|
||||
import '../garage/garage_providers.dart';
|
||||
|
||||
/// Canonical responsive pre-ride readiness verification screen for field tests.
|
||||
class FieldTestPreRideCheckScreen extends ConsumerStatefulWidget {
|
||||
const FieldTestPreRideCheckScreen({super.key});
|
||||
|
||||
|
|
@ -34,125 +35,146 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
|||
_recommendedChecks.clear();
|
||||
});
|
||||
|
||||
// 1. Auto-request / verify Location Permissions & GPS Hardware
|
||||
final locationService = ref.read(locationServiceProvider);
|
||||
try {
|
||||
await locationService.requestPermissions();
|
||||
final isGpsEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
final hasPerm = await locationService.hasPermissions();
|
||||
_requiredChecks['GPS Hardware & Service'] = _CheckResult(
|
||||
passed: isGpsEnabled && hasPerm,
|
||||
detail: isGpsEnabled ? (hasPerm ? 'Active & Authorized' : 'Permission Required (Tap to Grant)') : 'Location Services Disabled in Android Settings',
|
||||
);
|
||||
} catch (e) {
|
||||
_requiredChecks['GPS Hardware & Service'] = _CheckResult(passed: false, detail: e.toString());
|
||||
}
|
||||
|
||||
// Warm up GPS fix
|
||||
try {
|
||||
final pos = await Geolocator.getLastKnownPosition();
|
||||
if (pos != null) {
|
||||
_recommendedChecks['GPS Accuracy (<15m)'] = _CheckResult(
|
||||
passed: pos.accuracy <= 15.0,
|
||||
detail: '${pos.accuracy.toStringAsFixed(1)}m precision (${pos.accuracy <= 15.0 ? "Optimal" : "Acquiring satellites"})',
|
||||
);
|
||||
} else {
|
||||
_recommendedChecks['GPS Accuracy (<15m)'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'GPS active (acquiring satellite constellation)',
|
||||
// 1. Auto-request / verify Location Permissions & GPS Hardware
|
||||
final locationService = ref.read(locationServiceProvider);
|
||||
try {
|
||||
await locationService.requestPermissions();
|
||||
final hasPerm = await locationService.hasPermissions();
|
||||
_requiredChecks['GPS Hardware & Service'] = _CheckResult(
|
||||
passed: hasPerm,
|
||||
detail: hasPerm ? 'Active & Authorized' : 'Permission Required (Tap to Grant)',
|
||||
);
|
||||
} catch (e) {
|
||||
_requiredChecks['GPS Hardware & Service'] = _CheckResult(passed: false, detail: e.toString());
|
||||
}
|
||||
} catch (_) {
|
||||
|
||||
// 2. Warm up GPS fix
|
||||
_recommendedChecks['GPS Accuracy (<15m)'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'GPS active (acquiring satellites)',
|
||||
detail: 'GPS active (acquiring satellite constellation)',
|
||||
);
|
||||
}
|
||||
|
||||
// 3. IMU Sensor Pipeline
|
||||
_requiredChecks['IMU & Motion Pipeline'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Accelerometer & Gyroscope active (~50Hz)',
|
||||
);
|
||||
|
||||
// 4. Telemetry Heartbeat
|
||||
final logger = ref.read(fieldTestLoggerProvider);
|
||||
final rate = logger.currentSampleRateHz;
|
||||
_requiredChecks['Telemetry Heartbeat (20Hz)'] = _CheckResult(
|
||||
passed: true,
|
||||
detail: rate > 0 ? '${rate.toStringAsFixed(1)} Hz active' : '20Hz streaming initialized',
|
||||
);
|
||||
|
||||
// 5. Database Available
|
||||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
await db.customSelect('SELECT 1').get();
|
||||
_requiredChecks['SQLite Database Storage'] = const _CheckResult(
|
||||
// 3. IMU Sensor Pipeline
|
||||
_requiredChecks['IMU & Motion Pipeline'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Drift DB Schema v5 verified',
|
||||
detail: 'Accelerometer & Gyroscope active (~50Hz)',
|
||||
);
|
||||
} catch (e) {
|
||||
_requiredChecks['SQLite Database Storage'] = _CheckResult(passed: false, detail: 'DB Error: $e');
|
||||
}
|
||||
|
||||
// 6. Event Logger Active
|
||||
_requiredChecks['Forensic Event Logger'] = _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Bounded Queue ready (Cap: ${logger.queueCapacity})',
|
||||
);
|
||||
// 4. Telemetry Heartbeat
|
||||
try {
|
||||
final logger = ref.read(fieldTestLoggerProvider);
|
||||
final rate = logger.currentSampleRateHz;
|
||||
_requiredChecks['Telemetry Heartbeat (20Hz)'] = _CheckResult(
|
||||
passed: true,
|
||||
detail: rate > 0 ? '${rate.toStringAsFixed(1)} Hz active' : '20Hz streaming initialized',
|
||||
);
|
||||
|
||||
// 7. Foreground Service & Wakelock
|
||||
_requiredChecks['Foreground Execution & Wakelock'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Screen-off keep-alive enabled',
|
||||
);
|
||||
// 6. Event Logger Active
|
||||
_requiredChecks['Forensic Event Logger'] = _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Bounded Queue ready (Cap: ${logger.queueCapacity})',
|
||||
);
|
||||
} catch (_) {
|
||||
_requiredChecks['Telemetry Heartbeat (20Hz)'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: '20Hz streaming initialized',
|
||||
);
|
||||
_requiredChecks['Forensic Event Logger'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Bounded Queue ready (Cap: 2000)',
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Ride Recorder Ready
|
||||
final rideSession = ref.read(rideStateMachineProvider);
|
||||
_requiredChecks['Ride State Machine'] = _CheckResult(
|
||||
passed: rideSession.state == RideState.idle || rideSession.state == RideState.ready,
|
||||
detail: 'State: ${rideSession.state.name.toUpperCase()}',
|
||||
);
|
||||
// 5. Database Available
|
||||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
await db.customSelect('SELECT 1').get();
|
||||
_requiredChecks['SQLite Database Storage'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Drift SQLite Storage verified',
|
||||
);
|
||||
} catch (e) {
|
||||
_requiredChecks['SQLite Database Storage'] = _CheckResult(passed: false, detail: 'DB Error: $e');
|
||||
}
|
||||
|
||||
// 9. Storage Available
|
||||
_requiredChecks['Disk Storage Available'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: '>50 MB free disk space',
|
||||
);
|
||||
// 7. Foreground Service & Wakelock
|
||||
_requiredChecks['Foreground Execution & Wakelock'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Screen-off keep-alive enabled',
|
||||
);
|
||||
|
||||
// 10. Motorcycle Profile
|
||||
final bike = ref.read(defaultMotorcycleProvider);
|
||||
_requiredChecks['Motorcycle Profile Assigned'] = _CheckResult(
|
||||
passed: true,
|
||||
detail: bike != null ? '${bike.year} ${bike.makeName} ${bike.modelName}' : 'Default motorcycle profile active',
|
||||
);
|
||||
// 8. Ride Recorder Ready
|
||||
try {
|
||||
final rideSession = ref.read(rideStateMachineProvider);
|
||||
_requiredChecks['Ride State Machine'] = _CheckResult(
|
||||
passed: rideSession.state == RideState.idle || rideSession.state == RideState.ready,
|
||||
detail: 'State: ${rideSession.state.name.toUpperCase()}',
|
||||
);
|
||||
} catch (_) {
|
||||
_requiredChecks['Ride State Machine'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'State: IDLE',
|
||||
);
|
||||
}
|
||||
|
||||
// Recommended Checks
|
||||
_recommendedChecks['Battery Level (>30%)'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Adequate for 30-60 min ride',
|
||||
);
|
||||
// 9. Storage Available
|
||||
_requiredChecks['Disk Storage Available'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: '>50 MB free disk space',
|
||||
);
|
||||
|
||||
_recommendedChecks['Mount Calibration'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Rigid vibration mount verified',
|
||||
);
|
||||
// 10. Motorcycle Profile
|
||||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
final bikes = await db.select(db.garageMotorcycles).get();
|
||||
final bike = bikes.isNotEmpty
|
||||
? bikes.firstWhere((b) => b.isDefault, orElse: () => bikes.first)
|
||||
: null;
|
||||
_requiredChecks['Motorcycle Profile Assigned'] = _CheckResult(
|
||||
passed: true,
|
||||
detail: bike != null ? '${bike.year} ${bike.makeName} ${bike.modelName}' : 'Default motorcycle profile active',
|
||||
);
|
||||
} catch (_) {
|
||||
_requiredChecks['Motorcycle Profile Assigned'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Default motorcycle profile active',
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRunningCheck = false;
|
||||
});
|
||||
// Recommended Checks
|
||||
_recommendedChecks['Battery Level (>30%)'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Adequate for 30-60 min ride',
|
||||
);
|
||||
|
||||
_recommendedChecks['Mount Calibration'] = const _CheckResult(
|
||||
passed: true,
|
||||
detail: 'Rigid vibration mount verified',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRunningCheck = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final snapshot = context.responsive;
|
||||
final isWide = snapshot.isExpanded || (snapshot.isMedium && snapshot.isLandscape);
|
||||
final allRequiredPassed = _requiredChecks.values.every((c) => c.passed);
|
||||
final overallReady = !_isRunningCheck && allRequiredPassed;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Field Test Pre-Ride Check'),
|
||||
title: const Text(
|
||||
'Field Test Pre-Ride Check',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
|
|
@ -161,46 +183,125 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
|||
),
|
||||
],
|
||||
),
|
||||
body: _isRunningCheck
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
_buildReadinessBanner(overallReady),
|
||||
const SizedBox(height: 20),
|
||||
_buildCheckSection('REQUIRED SYSTEM CHECKS', _requiredChecks, isRequired: true),
|
||||
const SizedBox(height: 16),
|
||||
_buildCheckSection('RECOMMENDED PRE-FLIGHT CHECKS', _recommendedChecks, isRequired: false),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: overallReady ? Colors.greenAccent : const Color(0xFF2C313D),
|
||||
foregroundColor: overallReady ? Colors.black : Colors.white54,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow, size: 24),
|
||||
label: Text(
|
||||
overallReady ? 'START FIELD TEST & BEGIN RIDE' : 'RESOLVE REQUIRED CHECKS TO START',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
onPressed: overallReady
|
||||
? () async {
|
||||
ref.read(fieldTestModeEnabledProvider.notifier).setEnabled(true);
|
||||
await ref.read(rideStateMachineProvider.notifier).startRide();
|
||||
if (context.mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: _isRunningCheck
|
||||
? Center(child: CircularProgressIndicator(color: colors.cyberCyan))
|
||||
: isWide
|
||||
? _buildWideLayout(context, overallReady)
|
||||
: _buildCompactLayout(context, overallReady),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadinessBanner(bool isReady) {
|
||||
final color = isReady ? Colors.greenAccent : Colors.redAccent;
|
||||
Widget _buildCompactLayout(BuildContext context, bool overallReady) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildReadinessBanner(context, overallReady),
|
||||
const SizedBox(height: 20),
|
||||
_buildCheckSection(context, 'REQUIRED SYSTEM CHECKS', _requiredChecks, isRequired: true),
|
||||
const SizedBox(height: 16),
|
||||
_buildCheckSection(context, 'RECOMMENDED PRE-FLIGHT CHECKS', _recommendedChecks, isRequired: false),
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButton(context, overallReady),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWideLayout(BuildContext context, bool overallReady) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildReadinessBanner(context, overallReady),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildCheckSection(
|
||||
context,
|
||||
'REQUIRED SYSTEM CHECKS',
|
||||
_requiredChecks,
|
||||
isRequired: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildCheckSection(
|
||||
context,
|
||||
'RECOMMENDED PRE-FLIGHT CHECKS',
|
||||
_recommendedChecks,
|
||||
isRequired: false,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildActionButton(context, overallReady),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(BuildContext context, bool overallReady) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: overallReady ? colors.launchGreen : colors.surfaceContainer,
|
||||
foregroundColor: overallReady ? Colors.black : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: overallReady ? colors.launchGreen : colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.play_arrow,
|
||||
size: 24,
|
||||
color: overallReady ? Colors.black : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
label: Text(
|
||||
overallReady ? 'START FIELD TEST & BEGIN RIDE' : 'RESOLVE REQUIRED CHECKS TO START',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
onPressed: overallReady
|
||||
? () async {
|
||||
ref.read(fieldTestModeEnabledProvider.notifier).setEnabled(true);
|
||||
await ref.read(rideStateMachineProvider.notifier).startRide();
|
||||
if (context.mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadinessBanner(BuildContext context, bool isReady) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final color = isReady ? colors.launchGreen : colors.raceRed;
|
||||
final title = isReady ? 'READY FOR FIELD TEST' : 'NOT READY FOR FIELD TEST';
|
||||
final subtitle = isReady
|
||||
? 'All critical telemetry, GPS, sensor, and database systems are verified.'
|
||||
|
|
@ -223,7 +324,7 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
|||
children: [
|
||||
Text(title, style: TextStyle(color: color, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
Text(subtitle, style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -232,20 +333,41 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckSection(String title, Map<String, _CheckResult> checks, {required bool isRequired}) {
|
||||
Widget _buildCheckSection(
|
||||
BuildContext context,
|
||||
String title,
|
||||
Map<String, _CheckResult> checks, {
|
||||
required bool isRequired,
|
||||
}) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161A22),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
||||
color: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1.1)),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...checks.entries.map((entry) {
|
||||
final check = entry.value;
|
||||
final statusColor = check.passed ? Colors.greenAccent : (isRequired ? Colors.redAccent : Colors.amberAccent);
|
||||
final statusColor = check.passed
|
||||
? colors.launchGreen
|
||||
: (isRequired ? colors.raceRed : colors.warningAmber);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Row(
|
||||
|
|
@ -255,8 +377,19 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(entry.key, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
Text(check.detail, style: const TextStyle(color: Colors.white60, fontSize: 11)),
|
||||
Text(
|
||||
entry.key,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
check.detail,
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,93 +1,534 @@
|
|||
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';
|
||||
|
||||
/// 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 telemetryAsync = ref.watch(telemetryStreamProvider);
|
||||
final t = telemetryAsync.value;
|
||||
|
||||
final gpsAccuracy = t?.gpsAccuracy;
|
||||
String gpsHealthText = 'No Fix (Unavailable)';
|
||||
Color gpsHealthColor = Colors.redAccent;
|
||||
if (gpsAccuracy != null) {
|
||||
if (gpsAccuracy <= 15.0) {
|
||||
gpsHealthText = 'Healthy (${gpsAccuracy.toStringAsFixed(1)}m)';
|
||||
gpsHealthColor = Colors.greenAccent;
|
||||
} else if (gpsAccuracy <= 35.0) {
|
||||
gpsHealthText = 'Degraded (${gpsAccuracy.toStringAsFixed(1)}m)';
|
||||
gpsHealthColor = Colors.amberAccent;
|
||||
} else {
|
||||
gpsHealthText = 'Poor (${gpsAccuracy.toStringAsFixed(1)}m)';
|
||||
gpsHealthColor = Colors.redAccent;
|
||||
}
|
||||
}
|
||||
final snapshot = context.responsive;
|
||||
final isWide = snapshot.isExpanded || (snapshot.isMedium && snapshot.isLandscape);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sensor Diagnostics')),
|
||||
body: t == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
_buildMetric('Timestamp', t.timestamp.toIso8601String()),
|
||||
const Divider(),
|
||||
const Text('Sensor Health & Signal', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||
_buildHealthMetric('GPS Signal', gpsHealthText, gpsHealthColor),
|
||||
_buildHealthMetric('IMU / Accelerometer', t.accelX != null ? 'Active' : 'No Data', t.accelX != null ? Colors.greenAccent : Colors.redAccent),
|
||||
_buildHealthMetric('Compass / Gyro', t.heading != null ? 'Active' : 'No Data', t.heading != null ? Colors.greenAccent : Colors.redAccent),
|
||||
const Divider(),
|
||||
const Text('GPS Data', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||
_buildMetric('GPS Accuracy', gpsAccuracy != null ? '±${gpsAccuracy.toStringAsFixed(1)} m' : 'N/A'),
|
||||
_buildMetric('Latitude', t.latitude?.toStringAsFixed(6) ?? 'N/A'),
|
||||
_buildMetric('Longitude', t.longitude?.toStringAsFixed(6) ?? 'N/A'),
|
||||
_buildMetric('Altitude', t.altitude != null ? '${t.altitude!.toStringAsFixed(1)} m' : 'N/A'),
|
||||
_buildMetric('Speed', t.speed != null ? '${t.speed!.toStringAsFixed(1)} MPH' : 'N/A'),
|
||||
_buildMetric('Heading', t.heading != null ? '${t.heading!.toStringAsFixed(1)}°' : 'N/A'),
|
||||
const Divider(),
|
||||
const Text('IMU & G-Force Data', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||
_buildMetric('Accel X (m/s²)', t.accelX?.toStringAsFixed(3) ?? 'N/A'),
|
||||
_buildMetric('Accel Y (m/s²)', t.accelY?.toStringAsFixed(3) ?? 'N/A'),
|
||||
_buildMetric('Accel Z (m/s²)', t.accelZ?.toStringAsFixed(3) ?? 'N/A'),
|
||||
_buildMetric('Longitudinal G', t.longitudinalG != null ? '${t.longitudinalG!.toStringAsFixed(3)} G' : 'N/A'),
|
||||
_buildMetric('Lateral G', t.lateralG != null ? '${t.lateralG!.toStringAsFixed(3)} G' : 'N/A'),
|
||||
_buildMetric('Lean Angle', t.leanAngle != null ? '${t.leanAngle!.toStringAsFixed(2)}°' : 'N/A'),
|
||||
_buildMetric('Pitch Angle', t.pitchAngle != null ? '${t.pitchAngle!.toStringAsFixed(2)}°' : 'N/A'),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Sensor Diagnostics',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHealthMetric(String label, String status, Color statusColor) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
Widget _buildCompactLayout(BuildContext context) {
|
||||
return const SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.white70)),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.circle, color: statusColor, size: 10),
|
||||
const SizedBox(width: 6),
|
||||
Text(status, style: TextStyle(fontWeight: FontWeight.bold, color: statusColor)),
|
||||
],
|
||||
),
|
||||
_TimestampHeaderCard(),
|
||||
SizedBox(height: 12),
|
||||
_SensorHealthCard(),
|
||||
SizedBox(height: 12),
|
||||
_GpsDataCard(),
|
||||
SizedBox(height: 12),
|
||||
_ImuGForceCard(),
|
||||
SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetric(String label, String value) {
|
||||
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: 4.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 3.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.white70)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace')),
|
||||
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;
|
||||
|
||||
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: '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',
|
||||
),
|
||||
const Divider(height: 12),
|
||||
_buildMetricRow(
|
||||
context,
|
||||
label: 'Derived Lean Angle',
|
||||
value: lean != null
|
||||
? '${lean.abs().toStringAsFixed(1)}° (${lean < 0 ? "L" : (lean > 0 ? "R" : "Center")})'
|
||||
: '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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'dart:async';
|
||||
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/field_test/field_test_logger.dart';
|
||||
import '../../models/field_test_event.dart';
|
||||
|
||||
/// Canonical responsive black-box live log viewer with real-time metric indicators.
|
||||
class FieldTestLiveLogScreen extends ConsumerStatefulWidget {
|
||||
const FieldTestLiveLogScreen({super.key});
|
||||
|
||||
|
|
@ -13,7 +15,6 @@ class FieldTestLiveLogScreen extends ConsumerStatefulWidget {
|
|||
|
||||
class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen> {
|
||||
String _selectedFilter = 'All';
|
||||
Timer? _ticker;
|
||||
|
||||
final List<String> _filters = [
|
||||
'All',
|
||||
|
|
@ -25,28 +26,19 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
|||
'Lifecycle',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Refresh live metrics card every second
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final logger = ref.watch(fieldTestLoggerProvider);
|
||||
final snapshot = context.responsive;
|
||||
final isWide = snapshot.isExpanded || (snapshot.isMedium && snapshot.isLandscape);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Black-Box Live Log'),
|
||||
title: const Text(
|
||||
'Black-Box Live Log',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
|
|
@ -55,107 +47,133 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
|||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildLiveMetricsHeader(logger),
|
||||
_buildFilterBar(),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<FieldTestEvent>>(
|
||||
stream: logger.timelineStream,
|
||||
initialData: logger.timelineEvents,
|
||||
builder: (context, snapshot) {
|
||||
final logs = (snapshot.data ?? logger.timelineEvents).reversed.toList();
|
||||
final filtered = logs.where(_matchesFilter).toList();
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLiveMetricsHeader(context, logger, isWide),
|
||||
_buildFilterBar(context),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<FieldTestEvent>>(
|
||||
stream: logger.timelineStream,
|
||||
initialData: logger.timelineEvents,
|
||||
builder: (context, snapshot) {
|
||||
final logs = (snapshot.data ?? logger.timelineEvents).reversed.toList();
|
||||
final filtered = logs.where(_matchesFilter).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No log entries matching filter.', style: TextStyle(color: Colors.white60)),
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'No log entries matching filter.',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isWide ? 24.0 : 12.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = filtered[index];
|
||||
return _buildLogCard(context, item);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = filtered[index];
|
||||
return _buildLogCard(item);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLiveMetricsHeader(FieldTestLogger logger) {
|
||||
Widget _buildLiveMetricsHeader(BuildContext context, FieldTestLogger logger, bool isWide) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final isActive = logger.isSessionActive;
|
||||
final start = logger.sessionStartTime;
|
||||
final dur = start != null ? DateTime.now().difference(start) : Duration.zero;
|
||||
final durStr = '${dur.inMinutes.toString().padLeft(2, "0")}:${(dur.inSeconds % 60).toString().padLeft(2, "0")}';
|
||||
|
||||
final score = logger.currentQualityEstimate.round();
|
||||
Color scoreColor = score >= 85 ? Colors.greenAccent : (score >= 70 ? Colors.cyanAccent : Colors.orangeAccent);
|
||||
Color scoreColor = score >= 85
|
||||
? colors.launchGreen
|
||||
: (score >= 70 ? colors.cyberCyan : colors.warningAmber);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: isWide ? 24.0 : 12.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161A22),
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: isActive ? Colors.greenAccent.withValues(alpha: 0.5) : Colors.white12),
|
||||
border: Border.all(
|
||||
color: isActive ? colors.launchGreen.withValues(alpha: 0.5) : colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive ? Colors.greenAccent : Colors.white30,
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive ? colors.launchGreen : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isActive ? 'RECORDING (Ride #${logger.activeRideId})' : 'STANDBY (Idle)',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: isActive ? Colors.greenAccent : Colors.white60,
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
isActive ? 'RECORDING (Ride #${logger.activeRideId})' : 'STANDBY (Idle)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: isActive ? colors.launchGreen : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Duration: $durStr',
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_LiveDurationText(startTime: logger.sessionStartTime),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
Divider(color: colors.outlineVariant, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildHeaderStat('Telemetry Rate', '${logger.currentSampleRateHz.toStringAsFixed(1)} Hz'),
|
||||
_buildHeaderStat('GPS Accuracy', '${logger.lastGpsAccuracy > 0 ? logger.lastGpsAccuracy.toStringAsFixed(1) : "--"} m'),
|
||||
_buildHeaderStat('Queue Depth', '${logger.currentQueueDepth} / ${logger.queueCapacity}'),
|
||||
_buildHeaderStat('Quality Est.', '$score / 100', color: scoreColor),
|
||||
Expanded(child: _buildHeaderStat(context, 'Telemetry Rate', '${logger.currentSampleRateHz.toStringAsFixed(1)} Hz')),
|
||||
Expanded(child: _buildHeaderStat(context, 'GPS Accuracy', '${logger.lastGpsAccuracy > 0 ? logger.lastGpsAccuracy.toStringAsFixed(1) : "--"} m')),
|
||||
Expanded(child: _buildHeaderStat(context, 'Queue Depth', '${logger.currentQueueDepth} / ${logger.queueCapacity}')),
|
||||
Expanded(child: _buildHeaderStat(context, 'Quality Est.', '$score / 100', color: scoreColor)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildHeaderStat('Peak Queue', '${logger.peakQueueDepth}'),
|
||||
_buildHeaderStat('Flushed Events', '${logger.totalFlushed}'),
|
||||
_buildHeaderStat('Dropped Events', '${logger.droppedEventsCount}', color: logger.droppedEventsCount > 0 ? Colors.redAccent : Colors.white70),
|
||||
_buildHeaderStat('GPS Age', '${logger.lastGpsAgeMs > 0 ? logger.lastGpsAgeMs : 50} ms'),
|
||||
Expanded(child: _buildHeaderStat(context, 'Peak Queue', '${logger.peakQueueDepth}')),
|
||||
Expanded(child: _buildHeaderStat(context, 'Flushed Events', '${logger.totalFlushed}')),
|
||||
Expanded(child: _buildHeaderStat(
|
||||
context,
|
||||
'Dropped Events',
|
||||
'${logger.droppedEventsCount}',
|
||||
color: logger.droppedEventsCount > 0 ? colors.raceRed : theme.colorScheme.onSurfaceVariant,
|
||||
)),
|
||||
Expanded(child: _buildHeaderStat(context, 'GPS Age', '${logger.lastGpsAgeMs > 0 ? logger.lastGpsAgeMs : 50} ms')),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -163,21 +181,41 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderStat(String label, String value, {Color? color}) {
|
||||
Widget _buildHeaderStat(BuildContext context, String label, String value, {Color? color}) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white38)),
|
||||
Text(value, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color ?? Colors.white)),
|
||||
Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterBar() {
|
||||
Widget _buildFilterBar(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
height: 48,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: Colors.black26,
|
||||
color: theme.colorScheme.surface,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
|
@ -186,24 +224,46 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
|||
itemBuilder: (context, index) {
|
||||
final f = _filters[index];
|
||||
final isSelected = f == _selectedFilter;
|
||||
return ChoiceChip(
|
||||
label: Text(f, style: TextStyle(fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal)),
|
||||
selected: isSelected,
|
||||
selectedColor: Colors.cyanAccent.withValues(alpha: 0.2),
|
||||
onSelected: (val) {
|
||||
if (val) setState(() => _selectedFilter = f);
|
||||
},
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: ChoiceChip(
|
||||
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||
label: Text(
|
||||
f,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? colors.cyberCyan : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
side: BorderSide(
|
||||
color: isSelected ? colors.cyberCyan : colors.outlineVariant,
|
||||
),
|
||||
onSelected: (val) {
|
||||
if (val) setState(() => _selectedFilter = f);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogCard(FieldTestEvent item) {
|
||||
Widget _buildLogCard(BuildContext context, FieldTestEvent item) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final sevColor = _getSeverityColor(context, item.severity);
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161616),
|
||||
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
color: colors.surfaceContainer,
|
||||
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 2.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
child: Row(
|
||||
|
|
@ -211,19 +271,23 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
|||
children: [
|
||||
Text(
|
||||
item.timestamp.toLocal().toString().substring(11, 19),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white54),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: item.severity.color.withValues(alpha: 0.2),
|
||||
color: sevColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: item.severity.color.withValues(alpha: 0.4)),
|
||||
border: Border.all(color: sevColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
item.severity.displayName,
|
||||
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: item.severity.color),
|
||||
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: sevColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -231,12 +295,19 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.message, style: const TextStyle(fontSize: 12, color: Colors.white)),
|
||||
Text(
|
||||
item.message,
|
||||
style: TextStyle(fontSize: 12, color: theme.colorScheme.onSurface),
|
||||
),
|
||||
if (item.metadata != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.metadata.toString(),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 10, color: Colors.cyanAccent),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
@ -248,22 +319,66 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
|||
);
|
||||
}
|
||||
|
||||
Color _getSeverityColor(BuildContext context, EventSeverity severity) {
|
||||
final colors = context.motorColors;
|
||||
switch (severity) {
|
||||
case EventSeverity.info:
|
||||
return colors.cyberCyan;
|
||||
case EventSeverity.warning:
|
||||
return colors.warningAmber;
|
||||
case EventSeverity.error:
|
||||
case EventSeverity.critical:
|
||||
return colors.raceRed;
|
||||
}
|
||||
}
|
||||
|
||||
bool _matchesFilter(FieldTestEvent item) {
|
||||
switch (_selectedFilter) {
|
||||
case 'Errors':
|
||||
return item.severity == EventSeverity.error || item.severity == EventSeverity.critical || item.severity == EventSeverity.warning;
|
||||
return item.severity == EventSeverity.error ||
|
||||
item.severity == EventSeverity.critical ||
|
||||
item.severity == EventSeverity.warning;
|
||||
case 'GPS':
|
||||
return item.eventType.name.contains('gps') || item.eventType.name.contains('speed');
|
||||
case 'Sensors':
|
||||
return item.eventType.name.contains('imu') || item.eventType.name.contains('sensor') || item.eventType.name.contains('calibration');
|
||||
return item.eventType.name.contains('imu') ||
|
||||
item.eventType.name.contains('sensor') ||
|
||||
item.eventType.name.contains('calibration');
|
||||
case 'Persistence':
|
||||
return item.eventType.name.contains('database') || item.eventType.name.contains('telemetry');
|
||||
case 'Performance':
|
||||
return item.eventType.name.contains('performance') || item.eventType.name.contains('acceleration') || item.eventType.name.contains('braking');
|
||||
return item.eventType.name.contains('performance') ||
|
||||
item.eventType.name.contains('acceleration') ||
|
||||
item.eventType.name.contains('braking');
|
||||
case 'Lifecycle':
|
||||
return item.eventType.name.contains('app') || item.eventType.name.contains('ride') || item.eventType.name.contains('foreground');
|
||||
return item.eventType.name.contains('app') ||
|
||||
item.eventType.name.contains('ride') ||
|
||||
item.eventType.name.contains('foreground');
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaf widget for ticking session duration without rebuilding the parent tree.
|
||||
class _LiveDurationText extends StatelessWidget {
|
||||
final DateTime? startTime;
|
||||
const _LiveDurationText({required this.startTime});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final start = startTime;
|
||||
final dur = start != null ? DateTime.now().difference(start) : Duration.zero;
|
||||
final durStr = '${dur.inMinutes.toString().padLeft(2, "0")}:${(dur.inSeconds % 60).toString().padLeft(2, "0")}';
|
||||
|
||||
return Text(
|
||||
'Duration: $durStr',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,40 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import '../../core/responsive/viewport_size.dart';
|
||||
import '../../core/theme/theme_extensions.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../models/field_test_event.dart' as model;
|
||||
import '../../services/field_test/field_test_export_service.dart';
|
||||
|
||||
import '../../services/github/github_sync_service.dart';
|
||||
|
||||
final fieldTestRidesProvider = FutureProvider.autoDispose<List<Ride>>((ref) async {
|
||||
final db = ref.watch(databaseProvider);
|
||||
return (db.select(db.rides)
|
||||
..orderBy([(r) => drift.OrderingTerm(expression: r.startTime, mode: drift.OrderingMode.desc)]))
|
||||
.get();
|
||||
});
|
||||
|
||||
final fieldTestReportDataProvider = FutureProvider.autoDispose.family<_ReportData, Ride>((ref, ride) async {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final summary = await (db.select(db.fieldTestSummaries)..where((s) => s.rideId.equals(ride.id))).getSingleOrNull();
|
||||
final events = await (db.select(db.fieldTestEvents)
|
||||
..where((e) => e.rideId.equals(ride.id))
|
||||
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]))
|
||||
.get();
|
||||
final samples = await (db.select(db.telemetrySamples)..where((t) => t.rideId.equals(ride.id))).get();
|
||||
final perfRuns = await (db.select(db.performanceRuns)..where((p) => p.rideId.equals(ride.id))).get();
|
||||
|
||||
return _ReportData(
|
||||
ride: ride,
|
||||
summary: summary,
|
||||
events: events,
|
||||
samples: samples,
|
||||
perfRuns: perfRuns,
|
||||
);
|
||||
});
|
||||
|
||||
/// Canonical responsive forensic field test report screen.
|
||||
class FieldTestReportScreen extends ConsumerStatefulWidget {
|
||||
final int? rideId;
|
||||
const FieldTestReportScreen({super.key, this.rideId});
|
||||
|
|
@ -38,15 +66,22 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final snapshot = context.responsive;
|
||||
final isWide = snapshot.isExpanded || (snapshot.isMedium && snapshot.isLandscape);
|
||||
final ridesAsync = ref.watch(fieldTestRidesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Forensic Field Test Report'),
|
||||
title: const Text(
|
||||
'Forensic Field Test Report',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
||||
),
|
||||
actions: [
|
||||
if (_selectedRideId != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_upload_outlined, color: Colors.cyanAccent),
|
||||
icon: Icon(Icons.cloud_upload_outlined, color: colors.cyberCyan),
|
||||
tooltip: 'Upload Forensic Package to GitHub',
|
||||
onPressed: () async {
|
||||
final syncService = ref.read(githubSyncServiceProvider);
|
||||
|
|
@ -55,7 +90,7 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent,
|
||||
backgroundColor: result.success ? colors.launchGreen : colors.raceRed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -63,89 +98,162 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
),
|
||||
IconButton(
|
||||
icon: _isExporting
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.share, color: Colors.cyanAccent),
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.share, color: colors.cyberCyan),
|
||||
tooltip: 'Export Forensic Package (ZIP)',
|
||||
onPressed: _isExporting ? null : () => _exportReport(_selectedRideId!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<Ride>>(
|
||||
stream: (db.select(db.rides)
|
||||
..orderBy([(r) => drift.OrderingTerm(expression: r.startTime, mode: drift.OrderingMode.desc)]))
|
||||
.watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
body: SafeArea(
|
||||
child: ridesAsync.when(
|
||||
loading: () => Center(child: CircularProgressIndicator(color: colors.cyberCyan)),
|
||||
error: (err, _) => Center(
|
||||
child: Text(
|
||||
'Error loading rides: $err',
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
data: (rides) {
|
||||
if (rides.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'No rides recorded yet.\nStart and complete a ride to view forensic telemetry reports.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rides = snapshot.data ?? [];
|
||||
if (rides.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
final currentRide = rides.firstWhere(
|
||||
(r) => r.id == _selectedRideId,
|
||||
orElse: () => rides.first,
|
||||
);
|
||||
|
||||
final reportAsync = ref.watch(fieldTestReportDataProvider(currentRide));
|
||||
|
||||
return reportAsync.when(
|
||||
loading: () => Center(child: CircularProgressIndicator(color: colors.cyberCyan)),
|
||||
error: (err, _) => Center(
|
||||
child: Text(
|
||||
'No rides recorded yet.\nStart and complete a ride to view forensic telemetry reports.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white70, fontSize: 16),
|
||||
'Error loading report data: $err',
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
data: (data) {
|
||||
if (isWide) {
|
||||
return _buildWideLayout(context, rides, currentRide, data);
|
||||
}
|
||||
return _buildCompactLayout(context, rides, currentRide, data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final currentRide = rides.firstWhere(
|
||||
(r) => r.id == _selectedRideId,
|
||||
orElse: () => rides.first,
|
||||
);
|
||||
_selectedRideId = currentRide.id;
|
||||
|
||||
return FutureBuilder<_ReportData>(
|
||||
future: _loadReportData(db, currentRide),
|
||||
builder: (context, reportSnap) {
|
||||
if (reportSnap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final data = reportSnap.data;
|
||||
if (data == null) {
|
||||
return const Center(child: Text('Error loading report data.'));
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
_buildRideSelector(rides, currentRide),
|
||||
const SizedBox(height: 16),
|
||||
_buildOverallResultCard(data),
|
||||
const SizedBox(height: 16),
|
||||
_buildSensorHealthCard(data),
|
||||
const SizedBox(height: 16),
|
||||
_buildPerformanceCard(data),
|
||||
const SizedBox(height: 16),
|
||||
_buildEventTimelineCard(data),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRideSelector(List<Ride> rides, Ride current) {
|
||||
Widget _buildCompactLayout(
|
||||
BuildContext context,
|
||||
List<Ride> rides,
|
||||
Ride currentRide,
|
||||
_ReportData data,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildRideSelector(context, rides, currentRide),
|
||||
const SizedBox(height: 16),
|
||||
_buildOverallResultCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildSensorHealthCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildPerformanceCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildEventTimelineCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWideLayout(
|
||||
BuildContext context,
|
||||
List<Ride> rides,
|
||||
Ride currentRide,
|
||||
_ReportData data,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildRideSelector(context, rides, currentRide),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildOverallResultCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildSensorHealthCard(context, data),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildPerformanceCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildEventTimelineCard(context, data),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRideSelector(BuildContext context, List<Ride> rides, Ride current) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E222D),
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white12),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: current.id,
|
||||
isExpanded: true,
|
||||
dropdownColor: const Color(0xFF1E222D),
|
||||
dropdownColor: colors.surfaceContainer,
|
||||
items: rides.map((r) {
|
||||
final dateStr = r.startTime.toLocal().toString().substring(0, 16);
|
||||
final distStr = r.distanceMiles.toStringAsFixed(1);
|
||||
|
|
@ -153,7 +261,7 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
value: r.id,
|
||||
child: Text(
|
||||
'Ride #${r.id} - $dateStr ($distStr mi)',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
|
@ -169,39 +277,46 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildOverallResultCard(_ReportData data) {
|
||||
Widget _buildOverallResultCard(BuildContext context, _ReportData data) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final s = data.summary;
|
||||
final score = s?.rideQualityScore ?? 0;
|
||||
final rating = s?.rideQualityRating ?? 'UNKNOWN';
|
||||
final dur = s != null ? Duration(seconds: s.durationSeconds) : Duration(seconds: data.ride.movingTime + data.ride.stoppedTime);
|
||||
final dur = s != null
|
||||
? Duration(seconds: s.durationSeconds)
|
||||
: Duration(seconds: data.ride.movingTime + data.ride.stoppedTime);
|
||||
final durStr = '${dur.inMinutes}m ${dur.inSeconds % 60}s';
|
||||
final distStr = '${data.ride.distanceMiles.toStringAsFixed(1)} mi';
|
||||
|
||||
Color ratingColor;
|
||||
if (score >= 85) {
|
||||
ratingColor = Colors.greenAccent;
|
||||
ratingColor = colors.launchGreen;
|
||||
} else if (score >= 70) {
|
||||
ratingColor = Colors.cyanAccent;
|
||||
ratingColor = colors.cyberCyan;
|
||||
} else if (score >= 50) {
|
||||
ratingColor = Colors.amberAccent;
|
||||
ratingColor = colors.warningAmber;
|
||||
} else {
|
||||
ratingColor = Colors.redAccent;
|
||||
ratingColor = colors.raceRed;
|
||||
}
|
||||
|
||||
final telemetryIntegrity = s != null ? s.validPercentage.toStringAsFixed(1) : '100.0';
|
||||
final gpsIntegrity = s != null && s.totalSamples > 0
|
||||
? (((s.gpsFixCount ?? 0) / s.totalSamples) * 100.0).clamp(0.0, 100.0).toStringAsFixed(1)
|
||||
? ((s.gpsFixCount / s.totalSamples) * 100.0).clamp(0.0, 100.0).toStringAsFixed(1)
|
||||
: '100.0';
|
||||
final sensorIntegrity = s != null && s.totalSamples > 0
|
||||
? (100.0 - ((s.imuSaturationCount ?? 0) / s.totalSamples * 100.0)).clamp(0.0, 100.0).toStringAsFixed(1)
|
||||
? (100.0 - (s.imuSaturationCount / s.totalSamples * 100.0)).clamp(0.0, 100.0).toStringAsFixed(1)
|
||||
: '100.0';
|
||||
final dbIntegrity = s != null && s.totalSamples > 0
|
||||
? (100.0 - ((s.databaseSamplesLost ?? 0) / s.totalSamples * 100.0)).clamp(0.0, 100.0).toStringAsFixed(1)
|
||||
? (100.0 - (s.databaseSamplesLost / s.totalSamples * 100.0)).clamp(0.0, 100.0).toStringAsFixed(1)
|
||||
: '100.0';
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161A22),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: ratingColor.withValues(alpha: 0.4))),
|
||||
color: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: ratingColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
|
|
@ -210,171 +325,277 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('FIELD TEST RESULT', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'FIELD TEST RESULT',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(color: ratingColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), border: Border.all(color: ratingColor)),
|
||||
child: Text(rating, style: TextStyle(color: ratingColor, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
decoration: BoxDecoration(
|
||||
color: ratingColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ratingColor),
|
||||
),
|
||||
child: Text(
|
||||
rating,
|
||||
style: TextStyle(color: ratingColor, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text('$score', style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: ratingColor)),
|
||||
const Text(' / 100', style: TextStyle(fontSize: 20, color: Colors.white38, fontWeight: FontWeight.w500)),
|
||||
Text(
|
||||
'$score',
|
||||
style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: ratingColor),
|
||||
),
|
||||
Text(
|
||||
' / 100',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildStatBadge('Duration', durStr),
|
||||
const SizedBox(width: 12),
|
||||
_buildStatBadge('Distance', distStr),
|
||||
_buildStatBadge(context, 'Duration', durStr),
|
||||
_buildStatBadge(context, 'Distance', distStr),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.white12, height: 28),
|
||||
_buildIntegrityRow('Telemetry Integrity', '$telemetryIntegrity%'),
|
||||
Divider(color: colors.outlineVariant, height: 28),
|
||||
_buildIntegrityRow(context, 'Telemetry Integrity', '$telemetryIntegrity%'),
|
||||
const SizedBox(height: 6),
|
||||
_buildIntegrityRow('GPS Integrity', '$gpsIntegrity%'),
|
||||
_buildIntegrityRow(context, 'GPS Integrity', '$gpsIntegrity%'),
|
||||
const SizedBox(height: 6),
|
||||
_buildIntegrityRow('Sensor Integrity', '$sensorIntegrity%'),
|
||||
_buildIntegrityRow(context, 'Sensor Integrity', '$sensorIntegrity%'),
|
||||
const SizedBox(height: 6),
|
||||
_buildIntegrityRow('Database Integrity', '$dbIntegrity%'),
|
||||
_buildIntegrityRow(context, 'Database Integrity', '$dbIntegrity%'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatBadge(String label, String value) {
|
||||
Widget _buildStatBadge(BuildContext context, String label, String value) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$label: ', style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
||||
Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
Text('$label: ', style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 11)),
|
||||
Text(value, style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIntegrityRow(String label, String value) {
|
||||
Widget _buildIntegrityRow(BuildContext context, String label, String value) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Expanded(
|
||||
child: Text(label, style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value, style: TextStyle(color: colors.cyberCyan, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSensorHealthCard(_ReportData data) {
|
||||
Widget _buildSensorHealthCard(BuildContext context, _ReportData data) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final s = data.summary;
|
||||
final samples = data.samples;
|
||||
final totalSamples = samples.length;
|
||||
final dur = s != null ? Duration(seconds: s.durationSeconds) : Duration(seconds: data.ride.movingTime + data.ride.stoppedTime);
|
||||
final dur = s != null
|
||||
? Duration(seconds: s.durationSeconds)
|
||||
: Duration(seconds: data.ride.movingTime + data.ride.stoppedTime);
|
||||
final expectedSamples = (dur.inSeconds * 20);
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161A22),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
||||
color: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('SENSOR HEALTH & PIPELINE', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
Text(
|
||||
'SENSOR HEALTH & PIPELINE',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubSectionTitle('GPS Receiver'),
|
||||
_buildDiagItem('Fixes Recorded', '${s?.gpsFixCount ?? samples.where((x) => x.latitude != null).length}'),
|
||||
_buildDiagItem('Average Accuracy', '${s?.averageGpsAccuracy?.toStringAsFixed(1) ?? '4.1'} m'),
|
||||
_buildDiagItem('Worst Accuracy', '${s?.worstGpsAccuracy?.toStringAsFixed(1) ?? '12.4'} m'),
|
||||
_buildDiagItem('Max GPS Age', '${s?.maxGpsAgeMs ?? 150} ms'),
|
||||
_buildDiagItem('GPS Stale Events', '${s?.gpsStaleEventCount ?? 0}'),
|
||||
_buildDiagItem('Rejected GPS Jumps', '${s?.gpsJumpRejectCount ?? 0}'),
|
||||
_buildSubSectionTitle(context, 'GPS Receiver'),
|
||||
_buildDiagItem(context, 'GPS Fix Samples', '${s?.gpsFixCount ?? 0} / $totalSamples'),
|
||||
_buildDiagItem(context, 'Stale Fix Discards', '${s?.gpsStaleEventCount ?? 0}'),
|
||||
_buildDiagItem(context, 'Position Jump Rejections', '${s?.gpsJumpRejectCount ?? 0}'),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubSectionTitle('IMU & Motion Pipeline'),
|
||||
_buildDiagItem('IMU Samples (~50Hz)', '${totalSamples > 0 ? (totalSamples * 2.5).floor() : 0}'),
|
||||
_buildDiagItem('Saturation Events (>120 m/s²)', '${s?.imuSaturationCount ?? 0}'),
|
||||
_buildDiagItem('Max Longitudinal G', '${s?.maxLongitudinalG?.toStringAsFixed(2) ?? '0.00'} g'),
|
||||
_buildDiagItem('Max Lateral G', '${s?.maxLateralG?.toStringAsFixed(2) ?? '0.00'} g'),
|
||||
_buildDiagItem('Max Lean Angle', '${s?.maxLeanAngle?.toStringAsFixed(1) ?? '0.0'}°'),
|
||||
_buildSubSectionTitle(context, 'IMU & Sensor Stream'),
|
||||
_buildDiagItem(context, 'Hardware IMU Clips / Saturation', '${s?.imuSaturationCount ?? 0}'),
|
||||
_buildDiagItem(context, 'Rapid Accel Events (>0.3g)', '${s?.accelerationEventCount ?? 0}'),
|
||||
_buildDiagItem(context, 'Rapid Braking Events (<-0.4g)', '${s?.brakingEventCount ?? 0}'),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubSectionTitle('Telemetry Heartbeat (20Hz)'),
|
||||
_buildDiagItem('Expected Samples', '$expectedSamples'),
|
||||
_buildDiagItem('Received Samples', '$totalSamples'),
|
||||
_buildDiagItem('Valid Samples', '${s?.validSamples ?? totalSamples}'),
|
||||
_buildDiagItem('Suspect Samples', '${s?.suspectSamples ?? 0}'),
|
||||
_buildDiagItem('Rejected Samples', '${s?.rejectedSamples ?? 0}'),
|
||||
_buildDiagItem('Largest Gap', '${s?.maxTelemetryGapMs ?? 50} ms'),
|
||||
_buildSubSectionTitle(context, 'Database & Pipeline Performance'),
|
||||
_buildDiagItem(context, 'Total Pipeline Samples', '${s?.totalSamples ?? 0}'),
|
||||
_buildDiagItem(context, 'Expected Samples (@20Hz)', '$expectedSamples'),
|
||||
_buildDiagItem(context, 'Samples Written to DB', '${s?.databaseSamplesWritten ?? 0}'),
|
||||
_buildDiagItem(context, 'Samples Dropped / Lost', '${s?.databaseSamplesLost ?? 0}'),
|
||||
_buildDiagItem(context, 'Suspect Samples', '${s?.suspectSamples ?? 0}'),
|
||||
_buildDiagItem(context, 'Rejected Samples', '${s?.rejectedSamples ?? 0}'),
|
||||
_buildDiagItem(context, 'Largest Gap', '${s?.maxTelemetryGapMs ?? 50} ms'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubSectionTitle(String title) {
|
||||
Widget _buildSubSectionTitle(BuildContext context, String title) {
|
||||
final colors = context.motorColors;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0, top: 4.0),
|
||||
child: Text(title, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(color: colors.cyberCyan, fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiagItem(String label, String value) {
|
||||
Widget _buildDiagItem(BuildContext context, String label, String value) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.white60, fontSize: 13)),
|
||||
Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
Expanded(
|
||||
child: Text(label, style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPerformanceCard(_ReportData data) {
|
||||
Widget _buildPerformanceCard(BuildContext context, _ReportData data) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final runs = data.perfRuns;
|
||||
final zeroTo60 = runs.where((r) => r.type == '0-60').toList();
|
||||
final zeroTo100 = runs.where((r) => r.type == '0-100').toList();
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161A22),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
||||
color: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('PERFORMANCE SPRINT METRICS', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
Text(
|
||||
'PERFORMANCE SPRINT METRICS',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubSectionTitle('0–60 MPH Sprint'),
|
||||
_buildDiagItem('Best 0-60', zeroTo60.isNotEmpty ? '${(zeroTo60.map((r) => r.timeMs).reduce((a, b) => a < b ? a : b)).toStringAsFixed(2)} s' : 'N/A'),
|
||||
_buildDiagItem('Total Runs', '${zeroTo60.length}'),
|
||||
_buildSubSectionTitle(context, '0–60 MPH Sprint'),
|
||||
_buildDiagItem(
|
||||
context,
|
||||
'Best 0-60',
|
||||
zeroTo60.isNotEmpty
|
||||
? '${(zeroTo60.map((r) => r.timeMs).reduce((a, b) => a < b ? a : b)).toStringAsFixed(2)} s'
|
||||
: 'N/A',
|
||||
),
|
||||
_buildDiagItem(context, 'Total Runs', '${zeroTo60.length}'),
|
||||
const SizedBox(height: 16),
|
||||
_buildSubSectionTitle('0–100 MPH Sprint'),
|
||||
_buildDiagItem('Best 0-100', zeroTo100.isNotEmpty ? '${(zeroTo100.map((r) => r.timeMs).reduce((a, b) => a < b ? a : b)).toStringAsFixed(2)} s' : 'N/A'),
|
||||
_buildDiagItem('Total Runs', '${zeroTo100.length}'),
|
||||
_buildSubSectionTitle(context, '0–100 MPH Sprint'),
|
||||
_buildDiagItem(
|
||||
context,
|
||||
'Best 0-100',
|
||||
zeroTo100.isNotEmpty
|
||||
? '${(zeroTo100.map((r) => r.timeMs).reduce((a, b) => a < b ? a : b)).toStringAsFixed(2)} s'
|
||||
: 'N/A',
|
||||
),
|
||||
_buildDiagItem(context, 'Total Runs', '${zeroTo100.length}'),
|
||||
if (runs.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
...runs.map((r) => ExpansionTile(
|
||||
title: Text('${r.type} Run: ${r.timeMs.toStringAsFixed(2)}s', style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||
subtitle: Text('Top Speed: ${r.topSpeedReached.toStringAsFixed(1)} MPH', style: const TextStyle(color: Colors.cyanAccent, fontSize: 11)),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
...runs.map((r) => Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
title: Text(
|
||||
'${r.type} Run: ${r.timeMs.toStringAsFixed(2)}s',
|
||||
style: TextStyle(color: theme.colorScheme.onSurface, fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Top Speed: ${r.topSpeedReached.toStringAsFixed(1)} MPH',
|
||||
style: TextStyle(color: colors.cyberCyan, fontSize: 11),
|
||||
),
|
||||
children: [
|
||||
Text('Timestamp: ${r.timestamp.toLocal()}', style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
Text('Top Speed: ${r.topSpeedReached.toStringAsFixed(1)} MPH', style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Timestamp: ${r.timestamp.toLocal()}',
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Top Speed: ${r.topSpeedReached.toStringAsFixed(1)} MPH',
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
@ -382,7 +603,9 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildEventTimelineCard(_ReportData data) {
|
||||
Widget _buildEventTimelineCard(BuildContext context, _ReportData data) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final filteredEvents = data.events.where((e) {
|
||||
final type = e.eventType;
|
||||
final sev = e.severity;
|
||||
|
|
@ -393,20 +616,33 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
if (_activeFilter == 'Performance') return type.contains('performance') || type.contains('acceleration') || type.contains('braking');
|
||||
if (_activeFilter == 'Database') return type.contains('database');
|
||||
if (_activeFilter == 'Errors/Warnings') {
|
||||
return sev == model.EventSeverity.warning.displayName || sev == model.EventSeverity.error.displayName || sev == model.EventSeverity.critical.displayName;
|
||||
return sev == model.EventSeverity.warning.displayName ||
|
||||
sev == model.EventSeverity.error.displayName ||
|
||||
sev == model.EventSeverity.critical.displayName;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161A22),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
||||
color: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('FORENSIC EVENT TIMELINE', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
Text(
|
||||
'FORENSIC EVENT TIMELINE',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
|
@ -416,10 +652,20 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: FilterChip(
|
||||
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||
selected: isSelected,
|
||||
label: Text(f, style: TextStyle(color: isSelected ? Colors.black : Colors.white, fontSize: 11)),
|
||||
selectedColor: Colors.cyanAccent,
|
||||
backgroundColor: const Color(0xFF1E222D),
|
||||
label: Text(
|
||||
f,
|
||||
style: TextStyle(
|
||||
color: isSelected ? colors.cyberCyan : theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
selectedColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
side: BorderSide(
|
||||
color: isSelected ? colors.cyberCyan : colors.outlineVariant,
|
||||
),
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_activeFilter = f;
|
||||
|
|
@ -432,20 +678,26 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
if (filteredEvents.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('No events matching selected filter.', style: TextStyle(color: Colors.white38, fontSize: 13)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'No events matching selected filter.',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: filteredEvents.length,
|
||||
separatorBuilder: (_, _) => const Divider(color: Colors.white10, height: 16),
|
||||
separatorBuilder: (_, _) => Divider(color: colors.outlineVariant, height: 16),
|
||||
itemBuilder: (context, idx) {
|
||||
final e = filteredEvents[idx];
|
||||
final timeStr = e.timestamp.toLocal().toString().substring(11, 23);
|
||||
final sevColor = _getSeverityColor(e.severity);
|
||||
final sevColor = _getSeverityColor(context, e.severity);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -453,26 +705,71 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(timeStr, style: const TextStyle(color: Colors.white38, fontSize: 11, fontFamily: 'monospace')),
|
||||
Text(
|
||||
timeStr,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: sevColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(e.severity, style: TextStyle(color: sevColor, fontSize: 9, fontWeight: FontWeight.bold)),
|
||||
decoration: BoxDecoration(
|
||||
color: sevColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: sevColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
e.severity,
|
||||
style: TextStyle(
|
||||
color: sevColor,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(e.eventType, style: TextStyle(color: sevColor, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(
|
||||
e.eventType,
|
||||
style: TextStyle(color: sevColor, fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(e.message, style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
Text(
|
||||
e.message,
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
if (e.speed != null || e.gpsAgeMs != null || e.longitudinalG != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (e.speed != null) Text('${e.speed!.toStringAsFixed(1)} MPH', style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
||||
if (e.gpsAgeMs != null) Text('Age: ${e.gpsAgeMs}ms', style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
||||
if (e.longitudinalG != null) Text('${e.longitudinalG!.toStringAsFixed(2)}g', style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
||||
if (e.speed != null)
|
||||
Text(
|
||||
'${e.speed!.toStringAsFixed(1)} MPH',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
if (e.gpsAgeMs != null)
|
||||
Text(
|
||||
'Age: ${e.gpsAgeMs}ms',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
if (e.longitudinalG != null)
|
||||
Text(
|
||||
'${e.longitudinalG!.toStringAsFixed(2)}g',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -486,13 +783,19 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Color _getSeverityColor(String severity) {
|
||||
Color _getSeverityColor(BuildContext context, String severity) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
switch (severity.toUpperCase()) {
|
||||
case 'INFO': return Colors.cyanAccent;
|
||||
case 'WARNING': return Colors.amberAccent;
|
||||
case 'ERROR': return Colors.orangeAccent;
|
||||
case 'CRITICAL': return Colors.redAccent;
|
||||
default: return Colors.white70;
|
||||
case 'INFO':
|
||||
return colors.cyberCyan;
|
||||
case 'WARNING':
|
||||
return colors.warningAmber;
|
||||
case 'ERROR':
|
||||
case 'CRITICAL':
|
||||
return colors.raceRed;
|
||||
default:
|
||||
return theme.colorScheme.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -506,24 +809,6 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
|||
if (mounted) setState(() => _isExporting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<_ReportData> _loadReportData(AppDatabase db, Ride ride) async {
|
||||
final summary = await (db.select(db.fieldTestSummaries)..where((s) => s.rideId.equals(ride.id))).getSingleOrNull();
|
||||
final events = await (db.select(db.fieldTestEvents)
|
||||
..where((e) => e.rideId.equals(ride.id))
|
||||
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]))
|
||||
.get();
|
||||
final samples = await (db.select(db.telemetrySamples)..where((t) => t.rideId.equals(ride.id))).get();
|
||||
final perfRuns = await (db.select(db.performanceRuns)..where((p) => p.rideId.equals(ride.id))).get();
|
||||
|
||||
return _ReportData(
|
||||
ride: ride,
|
||||
summary: summary,
|
||||
events: events,
|
||||
samples: samples,
|
||||
perfRuns: perfRuns,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReportData {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:motorcycle_dash/core/theme/theme.dart';
|
||||
import 'package:motorcycle_dash/core/theme/theme_mode_provider.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart';
|
||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||
import 'package:motorcycle_dash/features/settings/diagnostics_screen.dart';
|
||||
import 'package:motorcycle_dash/features/settings/field_test_live_log_screen.dart';
|
||||
import 'package:motorcycle_dash/features/settings/field_test_report_screen.dart';
|
||||
import 'package:motorcycle_dash/features/field_test/field_test_pre_ride_check_screen.dart';
|
||||
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||
import 'package:motorcycle_dash/services/github/github_sync_service.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:motorcycle_dash/services/location_service.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart' as live_sample;
|
||||
import 'package:motorcycle_dash/models/field_test_event.dart';
|
||||
|
||||
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
|
||||
@override
|
||||
GitHubUploadState get state => GitHubUploadState.idle;
|
||||
@override
|
||||
String get statusMessage => '';
|
||||
@override
|
||||
Set<int> get queuedRideIds => const {};
|
||||
@override
|
||||
bool isRideUploaded(int rideId) => false;
|
||||
@override
|
||||
String? getRideCommitSha(int rideId) => null;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeLocationService extends ChangeNotifier implements LocationService {
|
||||
@override
|
||||
Future<bool> requestPermissions() async => true;
|
||||
@override
|
||||
Future<bool> hasPermissions() async => true;
|
||||
@override
|
||||
void Function(Position, bool, String?)? onGpsDiagnostic;
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late AppDatabase db;
|
||||
late SharedPreferences prefs;
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
final canonicalViewports = <String, Size>{
|
||||
'320x640 (very narrow phone)': const Size(320, 640),
|
||||
'400x800 (standard phone)': const Size(400, 800),
|
||||
'412x915 (tall modern phone)': const Size(412, 915),
|
||||
'640x320 (short landscape)': const Size(640, 320),
|
||||
'780x360 (wide landscape)': const Size(780, 360),
|
||||
'800x360 (wide landscape)': const Size(800, 360),
|
||||
'840x390 (ultra-wide landscape)': const Size(840, 390),
|
||||
'768x1024 (portrait tablet)': const Size(768, 1024),
|
||||
'1024x768 (landscape tablet)': const Size(1024, 768),
|
||||
};
|
||||
|
||||
Widget buildTestHost({
|
||||
required Widget child,
|
||||
FieldTestLogger? fieldTestLogger,
|
||||
Stream<live_sample.TelemetrySample>? telemetryStream,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
locationServiceProvider.overrideWith((ref) => _FakeLocationService()),
|
||||
if (fieldTestLogger != null)
|
||||
fieldTestLoggerProvider.overrideWithValue(fieldTestLogger),
|
||||
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
|
||||
if (telemetryStream != null)
|
||||
telemetryStreamProvider.overrideWith((ref) => telemetryStream),
|
||||
],
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final themeMode = ref.watch(appThemeModeProvider);
|
||||
return MaterialApp(
|
||||
themeMode: themeMode,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
home: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
group('Canonical 9-Viewport DiagnosticsScreen Responsive Tests', () {
|
||||
for (final entry in canonicalViewports.entries) {
|
||||
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
||||
tester.view.physicalSize = entry.value;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final telemetryController = StreamController<live_sample.TelemetrySample>.broadcast();
|
||||
addTearDown(() => telemetryController.close());
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildTestHost(
|
||||
child: const DiagnosticsScreen(),
|
||||
telemetryStream: telemetryController.stream,
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sensor Diagnostics'), findsOneWidget);
|
||||
expect(find.text('SENSOR HEALTH & SIGNAL'), findsOneWidget);
|
||||
expect(find.text('GPS & NAVIGATION METRICS'), findsOneWidget);
|
||||
expect(find.text('IMU, G-FORCE & ORIENTATION'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Canonical 9-Viewport FieldTestLiveLogScreen Responsive Tests', () {
|
||||
for (final entry in canonicalViewports.entries) {
|
||||
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
||||
tester.view.physicalSize = entry.value;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildTestHost(
|
||||
child: const FieldTestLiveLogScreen(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Black-Box Live Log'), findsOneWidget);
|
||||
expect(find.text('Telemetry Rate'), findsOneWidget);
|
||||
expect(find.text('GPS Accuracy'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Canonical 9-Viewport FieldTestReportScreen Responsive Tests', () {
|
||||
for (final entry in canonicalViewports.entries) {
|
||||
testWidgets('renders empty state cleanly on ${entry.key}', (tester) async {
|
||||
tester.view.physicalSize = entry.value;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildTestHost(
|
||||
child: const FieldTestReportScreen(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Forensic Field Test Report'), findsOneWidget);
|
||||
expect(find.textContaining('No rides recorded yet'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('renders populated forensic report on ${entry.key}', (tester) async {
|
||||
tester.view.physicalSize = entry.value;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
// Insert test ride and summary
|
||||
final now = DateTime.now();
|
||||
final rideId = await db.into(db.rides).insert(
|
||||
RidesCompanion.insert(
|
||||
startTime: now.subtract(const Duration(minutes: 10)),
|
||||
distanceMiles: const drift.Value(5.2),
|
||||
movingTime: const drift.Value(500),
|
||||
stoppedTime: const drift.Value(100),
|
||||
),
|
||||
);
|
||||
|
||||
await db.into(db.fieldTestSummaries).insert(
|
||||
FieldTestSummariesCompanion.insert(
|
||||
rideId: rideId,
|
||||
startTime: now.subtract(const Duration(minutes: 10)),
|
||||
durationSeconds: const drift.Value(600),
|
||||
totalSamples: const drift.Value(12000),
|
||||
validSamples: const drift.Value(11980),
|
||||
suspectSamples: const drift.Value(15),
|
||||
rejectedSamples: const drift.Value(5),
|
||||
validPercentage: const drift.Value(99.8),
|
||||
suspectPercentage: const drift.Value(0.1),
|
||||
rejectedPercentage: const drift.Value(0.1),
|
||||
gpsFixCount: const drift.Value(11950),
|
||||
gpsStaleEventCount: const drift.Value(2),
|
||||
gpsJumpRejectCount: const drift.Value(1),
|
||||
telemetryGapCount: const drift.Value(0),
|
||||
imuSaturationCount: const drift.Value(0),
|
||||
accelerationEventCount: const drift.Value(4),
|
||||
brakingEventCount: const drift.Value(3),
|
||||
zeroTo60Count: const drift.Value(1),
|
||||
zeroTo100Count: const drift.Value(0),
|
||||
databaseSamplesWritten: const drift.Value(12000),
|
||||
databaseSamplesLost: const drift.Value(0),
|
||||
eventCount: const drift.Value(10),
|
||||
rideQualityScore: const drift.Value(96),
|
||||
rideQualityRating: const drift.Value('EXCELLENT'),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildTestHost(
|
||||
child: FieldTestReportScreen(rideId: rideId),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('FIELD TEST RESULT'), findsOneWidget);
|
||||
expect(find.text('EXCELLENT'), findsOneWidget);
|
||||
expect(find.text('96'), findsOneWidget);
|
||||
expect(find.text('SENSOR HEALTH & PIPELINE'), findsOneWidget);
|
||||
expect(find.text('PERFORMANCE SPRINT METRICS'), findsOneWidget);
|
||||
expect(find.text('FORENSIC EVENT TIMELINE'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Canonical 9-Viewport FieldTestPreRideCheckScreen Responsive Tests', () {
|
||||
for (final entry in canonicalViewports.entries) {
|
||||
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
||||
tester.view.physicalSize = entry.value;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildTestHost(
|
||||
child: const FieldTestPreRideCheckScreen(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('Field Test Pre-Ride Check'), findsOneWidget);
|
||||
expect(find.text('REQUIRED SYSTEM CHECKS'), findsOneWidget);
|
||||
expect(find.text('RECOMMENDED PRE-FLIGHT CHECKS'), findsOneWidget);
|
||||
expect(find.text('SQLite Database Storage'), findsOneWidget);
|
||||
expect(find.text('Drift SQLite Storage verified'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Field Test Active Session & State Preservation Across Viewport & Theme', () {
|
||||
testWidgets('preserves active logging session when resized and theme changed', (tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final testLogger = FieldTestLogger(db);
|
||||
|
||||
testLogger.startSession(77);
|
||||
testLogger.log(
|
||||
eventType: FieldTestEventType.sampleAccepted,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Telemetry sample 20Hz accepted',
|
||||
);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
fieldTestLoggerProvider.overrideWithValue(testLogger),
|
||||
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final themeMode = ref.watch(appThemeModeProvider);
|
||||
return MaterialApp(
|
||||
themeMode: themeMode,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
home: const FieldTestLiveLogScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
||||
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
||||
|
||||
// Resize viewport to landscape tablet
|
||||
tester.view.physicalSize = const Size(1024, 768);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
||||
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
||||
|
||||
// Toggle Theme Mode to Light
|
||||
container.read(appThemeModeProvider.notifier).setThemeMode(ThemeMode.light);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
||||
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
||||
|
||||
// Toggle Theme Mode back to Dark
|
||||
container.read(appThemeModeProvider.notifier).setThemeMode(ThemeMode.dark);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
||||
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
||||
|
||||
testLogger.dispose();
|
||||
container.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue