feat: unify diagnostics and field test experience

This commit is contained in:
Developer 2026-09-05 05:35:39 -04:00
parent e6e870c991
commit 5c61a2b749
5 changed files with 1832 additions and 516 deletions

View File

@ -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),
),
],
),
),

View File

@ -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,
),
),
),
],
),
);

View File

@ -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,
),
);
}
}

View File

@ -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 {

View File

@ -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();
});
});
}