feat: unify diagnostics and field test experience
This commit is contained in:
parent
e6e870c991
commit
5c61a2b749
|
|
@ -1,14 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:go_router/go_router.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 '../../database/database.dart';
|
||||||
import '../../services/location_service.dart';
|
import '../../services/location_service.dart';
|
||||||
import '../../services/field_test/field_test_logger.dart';
|
import '../../services/field_test/field_test_logger.dart';
|
||||||
import '../rides/ride_state_machine.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 {
|
class FieldTestPreRideCheckScreen extends ConsumerStatefulWidget {
|
||||||
const FieldTestPreRideCheckScreen({super.key});
|
const FieldTestPreRideCheckScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -34,125 +35,146 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
||||||
_recommendedChecks.clear();
|
_recommendedChecks.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Auto-request / verify Location Permissions & GPS Hardware
|
|
||||||
final locationService = ref.read(locationServiceProvider);
|
|
||||||
try {
|
try {
|
||||||
await locationService.requestPermissions();
|
// 1. Auto-request / verify Location Permissions & GPS Hardware
|
||||||
final isGpsEnabled = await Geolocator.isLocationServiceEnabled();
|
final locationService = ref.read(locationServiceProvider);
|
||||||
final hasPerm = await locationService.hasPermissions();
|
try {
|
||||||
_requiredChecks['GPS Hardware & Service'] = _CheckResult(
|
await locationService.requestPermissions();
|
||||||
passed: isGpsEnabled && hasPerm,
|
final hasPerm = await locationService.hasPermissions();
|
||||||
detail: isGpsEnabled ? (hasPerm ? 'Active & Authorized' : 'Permission Required (Tap to Grant)') : 'Location Services Disabled in Android Settings',
|
_requiredChecks['GPS Hardware & Service'] = _CheckResult(
|
||||||
);
|
passed: hasPerm,
|
||||||
} catch (e) {
|
detail: hasPerm ? 'Active & Authorized' : 'Permission Required (Tap to Grant)',
|
||||||
_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)',
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_requiredChecks['GPS Hardware & Service'] = _CheckResult(passed: false, detail: e.toString());
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
|
// 2. Warm up GPS fix
|
||||||
_recommendedChecks['GPS Accuracy (<15m)'] = const _CheckResult(
|
_recommendedChecks['GPS Accuracy (<15m)'] = const _CheckResult(
|
||||||
passed: true,
|
passed: true,
|
||||||
detail: 'GPS active (acquiring satellites)',
|
detail: 'GPS active (acquiring satellite constellation)',
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 3. IMU Sensor Pipeline
|
// 3. IMU Sensor Pipeline
|
||||||
_requiredChecks['IMU & Motion Pipeline'] = const _CheckResult(
|
_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(
|
|
||||||
passed: true,
|
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
|
// 4. Telemetry Heartbeat
|
||||||
_requiredChecks['Forensic Event Logger'] = _CheckResult(
|
try {
|
||||||
passed: true,
|
final logger = ref.read(fieldTestLoggerProvider);
|
||||||
detail: 'Bounded Queue ready (Cap: ${logger.queueCapacity})',
|
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
|
// 6. Event Logger Active
|
||||||
_requiredChecks['Foreground Execution & Wakelock'] = const _CheckResult(
|
_requiredChecks['Forensic Event Logger'] = _CheckResult(
|
||||||
passed: true,
|
passed: true,
|
||||||
detail: 'Screen-off keep-alive enabled',
|
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
|
// 5. Database Available
|
||||||
final rideSession = ref.read(rideStateMachineProvider);
|
try {
|
||||||
_requiredChecks['Ride State Machine'] = _CheckResult(
|
final db = ref.read(databaseProvider);
|
||||||
passed: rideSession.state == RideState.idle || rideSession.state == RideState.ready,
|
await db.customSelect('SELECT 1').get();
|
||||||
detail: 'State: ${rideSession.state.name.toUpperCase()}',
|
_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
|
// 7. Foreground Service & Wakelock
|
||||||
_requiredChecks['Disk Storage Available'] = const _CheckResult(
|
_requiredChecks['Foreground Execution & Wakelock'] = const _CheckResult(
|
||||||
passed: true,
|
passed: true,
|
||||||
detail: '>50 MB free disk space',
|
detail: 'Screen-off keep-alive enabled',
|
||||||
);
|
);
|
||||||
|
|
||||||
// 10. Motorcycle Profile
|
// 8. Ride Recorder Ready
|
||||||
final bike = ref.read(defaultMotorcycleProvider);
|
try {
|
||||||
_requiredChecks['Motorcycle Profile Assigned'] = _CheckResult(
|
final rideSession = ref.read(rideStateMachineProvider);
|
||||||
passed: true,
|
_requiredChecks['Ride State Machine'] = _CheckResult(
|
||||||
detail: bike != null ? '${bike.year} ${bike.makeName} ${bike.modelName}' : 'Default motorcycle profile active',
|
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
|
// 9. Storage Available
|
||||||
_recommendedChecks['Battery Level (>30%)'] = const _CheckResult(
|
_requiredChecks['Disk Storage Available'] = const _CheckResult(
|
||||||
passed: true,
|
passed: true,
|
||||||
detail: 'Adequate for 30-60 min ride',
|
detail: '>50 MB free disk space',
|
||||||
);
|
);
|
||||||
|
|
||||||
_recommendedChecks['Mount Calibration'] = const _CheckResult(
|
// 10. Motorcycle Profile
|
||||||
passed: true,
|
try {
|
||||||
detail: 'Rigid vibration mount verified',
|
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) {
|
// Recommended Checks
|
||||||
setState(() {
|
_recommendedChecks['Battery Level (>30%)'] = const _CheckResult(
|
||||||
_isRunningCheck = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 allRequiredPassed = _requiredChecks.values.every((c) => c.passed);
|
||||||
final overallReady = !_isRunningCheck && allRequiredPassed;
|
final overallReady = !_isRunningCheck && allRequiredPassed;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
|
|
@ -161,46 +183,125 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _isRunningCheck
|
body: SafeArea(
|
||||||
? const Center(child: CircularProgressIndicator())
|
child: _isRunningCheck
|
||||||
: ListView(
|
? Center(child: CircularProgressIndicator(color: colors.cyberCyan))
|
||||||
padding: const EdgeInsets.all(16.0),
|
: isWide
|
||||||
children: [
|
? _buildWideLayout(context, overallReady)
|
||||||
_buildReadinessBanner(overallReady),
|
: _buildCompactLayout(context, 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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReadinessBanner(bool isReady) {
|
Widget _buildCompactLayout(BuildContext context, bool overallReady) {
|
||||||
final color = isReady ? Colors.greenAccent : Colors.redAccent;
|
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 title = isReady ? 'READY FOR FIELD TEST' : 'NOT READY FOR FIELD TEST';
|
||||||
final subtitle = isReady
|
final subtitle = isReady
|
||||||
? 'All critical telemetry, GPS, sensor, and database systems are verified.'
|
? 'All critical telemetry, GPS, sensor, and database systems are verified.'
|
||||||
|
|
@ -223,7 +324,7 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: TextStyle(color: color, fontSize: 16, fontWeight: FontWeight.bold)),
|
Text(title, style: TextStyle(color: color, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 4),
|
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(
|
return Card(
|
||||||
color: const Color(0xFF161A22),
|
color: colors.surfaceContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(height: 12),
|
||||||
...checks.entries.map((entry) {
|
...checks.entries.map((entry) {
|
||||||
final check = entry.value;
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -255,8 +377,19 @@ class _FieldTestPreRideCheckScreenState extends ConsumerState<FieldTestPreRideCh
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(entry.key, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
Text(
|
||||||
Text(check.detail, style: const TextStyle(color: Colors.white60, fontSize: 11)),
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/responsive/viewport_size.dart';
|
||||||
|
import '../../core/theme/theme_extensions.dart';
|
||||||
import '../../services/telemetry_service.dart';
|
import '../../services/telemetry_service.dart';
|
||||||
|
|
||||||
|
/// Canonical responsive diagnostics screen displaying sensor health, GNSS fidelity, and raw IMU readings.
|
||||||
class DiagnosticsScreen extends ConsumerWidget {
|
class DiagnosticsScreen extends ConsumerWidget {
|
||||||
const DiagnosticsScreen({super.key});
|
const DiagnosticsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final telemetryAsync = ref.watch(telemetryStreamProvider);
|
final snapshot = context.responsive;
|
||||||
final t = telemetryAsync.value;
|
final isWide = snapshot.isExpanded || (snapshot.isMedium && snapshot.isLandscape);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sensor Diagnostics')),
|
appBar: AppBar(
|
||||||
body: t == null
|
title: const Text(
|
||||||
? const Center(child: CircularProgressIndicator())
|
'Sensor Diagnostics',
|
||||||
: ListView(
|
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
||||||
padding: const EdgeInsets.all(16.0),
|
),
|
||||||
children: [
|
),
|
||||||
_buildMetric('Timestamp', t.timestamp.toIso8601String()),
|
body: SafeArea(
|
||||||
const Divider(),
|
child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context),
|
||||||
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'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHealthMetric(String label, String status, Color statusColor) {
|
Widget _buildCompactLayout(BuildContext context) {
|
||||||
return Padding(
|
return const SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
padding: EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(color: Colors.white70)),
|
_TimestampHeaderCard(),
|
||||||
Row(
|
SizedBox(height: 12),
|
||||||
children: [
|
_SensorHealthCard(),
|
||||||
Icon(Icons.circle, color: statusColor, size: 10),
|
SizedBox(height: 12),
|
||||||
const SizedBox(width: 6),
|
_GpsDataCard(),
|
||||||
Text(status, style: TextStyle(fontWeight: FontWeight.bold, color: statusColor)),
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
padding: const EdgeInsets.symmetric(vertical: 3.5),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(color: Colors.white70)),
|
Expanded(
|
||||||
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace')),
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../services/field_test/field_test_logger.dart';
|
||||||
import '../../models/field_test_event.dart';
|
import '../../models/field_test_event.dart';
|
||||||
|
|
||||||
|
/// Canonical responsive black-box live log viewer with real-time metric indicators.
|
||||||
class FieldTestLiveLogScreen extends ConsumerStatefulWidget {
|
class FieldTestLiveLogScreen extends ConsumerStatefulWidget {
|
||||||
const FieldTestLiveLogScreen({super.key});
|
const FieldTestLiveLogScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -13,7 +15,6 @@ class FieldTestLiveLogScreen extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen> {
|
class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen> {
|
||||||
String _selectedFilter = 'All';
|
String _selectedFilter = 'All';
|
||||||
Timer? _ticker;
|
|
||||||
|
|
||||||
final List<String> _filters = [
|
final List<String> _filters = [
|
||||||
'All',
|
'All',
|
||||||
|
|
@ -25,28 +26,19 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
||||||
'Lifecycle',
|
'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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final logger = ref.watch(fieldTestLoggerProvider);
|
final logger = ref.watch(fieldTestLoggerProvider);
|
||||||
|
final snapshot = context.responsive;
|
||||||
|
final isWide = snapshot.isExpanded || (snapshot.isMedium && snapshot.isLandscape);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
|
|
@ -55,107 +47,133 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: SafeArea(
|
||||||
children: [
|
child: Column(
|
||||||
_buildLiveMetricsHeader(logger),
|
children: [
|
||||||
_buildFilterBar(),
|
_buildLiveMetricsHeader(context, logger, isWide),
|
||||||
Expanded(
|
_buildFilterBar(context),
|
||||||
child: StreamBuilder<List<FieldTestEvent>>(
|
Expanded(
|
||||||
stream: logger.timelineStream,
|
child: StreamBuilder<List<FieldTestEvent>>(
|
||||||
initialData: logger.timelineEvents,
|
stream: logger.timelineStream,
|
||||||
builder: (context, snapshot) {
|
initialData: logger.timelineEvents,
|
||||||
final logs = (snapshot.data ?? logger.timelineEvents).reversed.toList();
|
builder: (context, snapshot) {
|
||||||
final filtered = logs.where(_matchesFilter).toList();
|
final logs = (snapshot.data ?? logger.timelineEvents).reversed.toList();
|
||||||
|
final filtered = logs.where(_matchesFilter).toList();
|
||||||
|
|
||||||
if (filtered.isEmpty) {
|
if (filtered.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text('No log entries matching filter.', style: TextStyle(color: Colors.white60)),
|
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 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();
|
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(
|
return Container(
|
||||||
margin: const EdgeInsets.all(12),
|
margin: EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.all(12),
|
horizontal: isWide ? 24.0 : 12.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF161A22),
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Expanded(
|
||||||
children: [
|
child: Row(
|
||||||
Container(
|
children: [
|
||||||
width: 10,
|
Container(
|
||||||
height: 10,
|
width: 10,
|
||||||
decoration: BoxDecoration(
|
height: 10,
|
||||||
shape: BoxShape.circle,
|
decoration: BoxDecoration(
|
||||||
color: isActive ? Colors.greenAccent : Colors.white30,
|
shape: BoxShape.circle,
|
||||||
|
color: isActive ? colors.launchGreen : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Flexible(
|
||||||
Text(
|
child: Text(
|
||||||
isActive ? 'RECORDING (Ride #${logger.activeRideId})' : 'STANDBY (Idle)',
|
isActive ? 'RECORDING (Ride #${logger.activeRideId})' : 'STANDBY (Idle)',
|
||||||
style: TextStyle(
|
overflow: TextOverflow.ellipsis,
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontWeight: FontWeight.bold,
|
||||||
color: isActive ? Colors.greenAccent : Colors.white60,
|
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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
_buildHeaderStat('Telemetry Rate', '${logger.currentSampleRateHz.toStringAsFixed(1)} Hz'),
|
Expanded(child: _buildHeaderStat(context, 'Telemetry Rate', '${logger.currentSampleRateHz.toStringAsFixed(1)} Hz')),
|
||||||
_buildHeaderStat('GPS Accuracy', '${logger.lastGpsAccuracy > 0 ? logger.lastGpsAccuracy.toStringAsFixed(1) : "--"} m'),
|
Expanded(child: _buildHeaderStat(context, 'GPS Accuracy', '${logger.lastGpsAccuracy > 0 ? logger.lastGpsAccuracy.toStringAsFixed(1) : "--"} m')),
|
||||||
_buildHeaderStat('Queue Depth', '${logger.currentQueueDepth} / ${logger.queueCapacity}'),
|
Expanded(child: _buildHeaderStat(context, 'Queue Depth', '${logger.currentQueueDepth} / ${logger.queueCapacity}')),
|
||||||
_buildHeaderStat('Quality Est.', '$score / 100', color: scoreColor),
|
Expanded(child: _buildHeaderStat(context, 'Quality Est.', '$score / 100', color: scoreColor)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
_buildHeaderStat('Peak Queue', '${logger.peakQueueDepth}'),
|
Expanded(child: _buildHeaderStat(context, 'Peak Queue', '${logger.peakQueueDepth}')),
|
||||||
_buildHeaderStat('Flushed Events', '${logger.totalFlushed}'),
|
Expanded(child: _buildHeaderStat(context, 'Flushed Events', '${logger.totalFlushed}')),
|
||||||
_buildHeaderStat('Dropped Events', '${logger.droppedEventsCount}', color: logger.droppedEventsCount > 0 ? Colors.redAccent : Colors.white70),
|
Expanded(child: _buildHeaderStat(
|
||||||
_buildHeaderStat('GPS Age', '${logger.lastGpsAgeMs > 0 ? logger.lastGpsAgeMs : 50} ms'),
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white38)),
|
Text(
|
||||||
Text(value, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color ?? Colors.white)),
|
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(
|
return Container(
|
||||||
height: 48,
|
height: 52,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
color: Colors.black26,
|
color: theme.colorScheme.surface,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
|
@ -186,24 +224,46 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final f = _filters[index];
|
final f = _filters[index];
|
||||||
final isSelected = f == _selectedFilter;
|
final isSelected = f == _selectedFilter;
|
||||||
return ChoiceChip(
|
return Material(
|
||||||
label: Text(f, style: TextStyle(fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal)),
|
color: Colors.transparent,
|
||||||
selected: isSelected,
|
child: ChoiceChip(
|
||||||
selectedColor: Colors.cyanAccent.withValues(alpha: 0.2),
|
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||||
onSelected: (val) {
|
label: Text(
|
||||||
if (val) setState(() => _selectedFilter = f);
|
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(
|
return Card(
|
||||||
color: const Color(0xFF161616),
|
color: colors.surfaceContainer,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
|
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 2.0),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -211,19 +271,23 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
item.timestamp.toLocal().toString().substring(11, 19),
|
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),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: item.severity.color.withValues(alpha: 0.2),
|
color: sevColor.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(4),
|
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(
|
child: Text(
|
||||||
item.severity.displayName,
|
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),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -231,12 +295,19 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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) ...[
|
if (item.metadata != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
item.metadata.toString(),
|
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) {
|
bool _matchesFilter(FieldTestEvent item) {
|
||||||
switch (_selectedFilter) {
|
switch (_selectedFilter) {
|
||||||
case 'Errors':
|
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':
|
case 'GPS':
|
||||||
return item.eventType.name.contains('gps') || item.eventType.name.contains('speed');
|
return item.eventType.name.contains('gps') || item.eventType.name.contains('speed');
|
||||||
case 'Sensors':
|
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':
|
case 'Persistence':
|
||||||
return item.eventType.name.contains('database') || item.eventType.name.contains('telemetry');
|
return item.eventType.name.contains('database') || item.eventType.name.contains('telemetry');
|
||||||
case 'Performance':
|
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':
|
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:
|
default:
|
||||||
return true;
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import '../../core/responsive/viewport_size.dart';
|
||||||
|
import '../../core/theme/theme_extensions.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../models/field_test_event.dart' as model;
|
import '../../models/field_test_event.dart' as model;
|
||||||
import '../../services/field_test/field_test_export_service.dart';
|
import '../../services/field_test/field_test_export_service.dart';
|
||||||
|
|
||||||
import '../../services/github/github_sync_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 {
|
class FieldTestReportScreen extends ConsumerStatefulWidget {
|
||||||
final int? rideId;
|
final int? rideId;
|
||||||
const FieldTestReportScreen({super.key, this.rideId});
|
const FieldTestReportScreen({super.key, this.rideId});
|
||||||
|
|
@ -38,15 +66,22 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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: [
|
actions: [
|
||||||
if (_selectedRideId != null) ...[
|
if (_selectedRideId != null) ...[
|
||||||
IconButton(
|
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',
|
tooltip: 'Upload Forensic Package to GitHub',
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final syncService = ref.read(githubSyncServiceProvider);
|
final syncService = ref.read(githubSyncServiceProvider);
|
||||||
|
|
@ -55,7 +90,7 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(result.message),
|
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(
|
IconButton(
|
||||||
icon: _isExporting
|
icon: _isExporting
|
||||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
? SizedBox(
|
||||||
: const Icon(Icons.share, color: Colors.cyanAccent),
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(Icons.share, color: colors.cyberCyan),
|
||||||
tooltip: 'Export Forensic Package (ZIP)',
|
tooltip: 'Export Forensic Package (ZIP)',
|
||||||
onPressed: _isExporting ? null : () => _exportReport(_selectedRideId!),
|
onPressed: _isExporting ? null : () => _exportReport(_selectedRideId!),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: StreamBuilder<List<Ride>>(
|
body: SafeArea(
|
||||||
stream: (db.select(db.rides)
|
child: ridesAsync.when(
|
||||||
..orderBy([(r) => drift.OrderingTerm(expression: r.startTime, mode: drift.OrderingMode.desc)]))
|
loading: () => Center(child: CircularProgressIndicator(color: colors.cyberCyan)),
|
||||||
.watch(),
|
error: (err, _) => Center(
|
||||||
builder: (context, snapshot) {
|
child: Text(
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
'Error loading rides: $err',
|
||||||
return const Center(child: CircularProgressIndicator());
|
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 ?? [];
|
final currentRide = rides.firstWhere(
|
||||||
if (rides.isEmpty) {
|
(r) => r.id == _selectedRideId,
|
||||||
return const Center(
|
orElse: () => rides.first,
|
||||||
child: Padding(
|
);
|
||||||
padding: EdgeInsets.all(24.0),
|
|
||||||
|
final reportAsync = ref.watch(fieldTestReportDataProvider(currentRide));
|
||||||
|
|
||||||
|
return reportAsync.when(
|
||||||
|
loading: () => Center(child: CircularProgressIndicator(color: colors.cyberCyan)),
|
||||||
|
error: (err, _) => Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No rides recorded yet.\nStart and complete a ride to view forensic telemetry reports.',
|
'Error loading report data: $err',
|
||||||
textAlign: TextAlign.center,
|
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 16),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1E222D),
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white12),
|
border: Border.all(color: colors.outlineVariant),
|
||||||
),
|
),
|
||||||
child: DropdownButtonHideUnderline(
|
child: DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<int>(
|
child: DropdownButton<int>(
|
||||||
value: current.id,
|
value: current.id,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
dropdownColor: const Color(0xFF1E222D),
|
dropdownColor: colors.surfaceContainer,
|
||||||
items: rides.map((r) {
|
items: rides.map((r) {
|
||||||
final dateStr = r.startTime.toLocal().toString().substring(0, 16);
|
final dateStr = r.startTime.toLocal().toString().substring(0, 16);
|
||||||
final distStr = r.distanceMiles.toStringAsFixed(1);
|
final distStr = r.distanceMiles.toStringAsFixed(1);
|
||||||
|
|
@ -153,7 +261,7 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||||
value: r.id,
|
value: r.id,
|
||||||
child: Text(
|
child: Text(
|
||||||
'Ride #${r.id} - $dateStr ($distStr mi)',
|
'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(),
|
}).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 s = data.summary;
|
||||||
final score = s?.rideQualityScore ?? 0;
|
final score = s?.rideQualityScore ?? 0;
|
||||||
final rating = s?.rideQualityRating ?? 'UNKNOWN';
|
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 durStr = '${dur.inMinutes}m ${dur.inSeconds % 60}s';
|
||||||
final distStr = '${data.ride.distanceMiles.toStringAsFixed(1)} mi';
|
final distStr = '${data.ride.distanceMiles.toStringAsFixed(1)} mi';
|
||||||
|
|
||||||
Color ratingColor;
|
Color ratingColor;
|
||||||
if (score >= 85) {
|
if (score >= 85) {
|
||||||
ratingColor = Colors.greenAccent;
|
ratingColor = colors.launchGreen;
|
||||||
} else if (score >= 70) {
|
} else if (score >= 70) {
|
||||||
ratingColor = Colors.cyanAccent;
|
ratingColor = colors.cyberCyan;
|
||||||
} else if (score >= 50) {
|
} else if (score >= 50) {
|
||||||
ratingColor = Colors.amberAccent;
|
ratingColor = colors.warningAmber;
|
||||||
} else {
|
} else {
|
||||||
ratingColor = Colors.redAccent;
|
ratingColor = colors.raceRed;
|
||||||
}
|
}
|
||||||
|
|
||||||
final telemetryIntegrity = s != null ? s.validPercentage.toStringAsFixed(1) : '100.0';
|
final telemetryIntegrity = s != null ? s.validPercentage.toStringAsFixed(1) : '100.0';
|
||||||
final gpsIntegrity = s != null && s.totalSamples > 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';
|
: '100.0';
|
||||||
final sensorIntegrity = s != null && s.totalSamples > 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';
|
: '100.0';
|
||||||
final dbIntegrity = s != null && s.totalSamples > 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';
|
: '100.0';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: const Color(0xFF161A22),
|
color: colors.surfaceContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: ratingColor.withValues(alpha: 0.4))),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: ratingColor.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -210,171 +325,277 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
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)),
|
decoration: BoxDecoration(
|
||||||
child: Text(rating, style: TextStyle(color: ratingColor, fontWeight: FontWeight.bold, fontSize: 12)),
|
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),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('$score', style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: ratingColor)),
|
Text(
|
||||||
const Text(' / 100', style: TextStyle(fontSize: 20, color: Colors.white38, fontWeight: FontWeight.w500)),
|
'$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),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
_buildStatBadge('Duration', durStr),
|
_buildStatBadge(context, 'Duration', durStr),
|
||||||
const SizedBox(width: 12),
|
_buildStatBadge(context, 'Distance', distStr),
|
||||||
_buildStatBadge('Distance', distStr),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(color: Colors.white12, height: 28),
|
Divider(color: colors.outlineVariant, height: 28),
|
||||||
_buildIntegrityRow('Telemetry Integrity', '$telemetryIntegrity%'),
|
_buildIntegrityRow(context, 'Telemetry Integrity', '$telemetryIntegrity%'),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_buildIntegrityRow('GPS Integrity', '$gpsIntegrity%'),
|
_buildIntegrityRow(context, 'GPS Integrity', '$gpsIntegrity%'),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_buildIntegrityRow('Sensor Integrity', '$sensorIntegrity%'),
|
_buildIntegrityRow(context, 'Sensor Integrity', '$sensorIntegrity%'),
|
||||||
const SizedBox(height: 6),
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8)),
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('$label: ', style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
Text('$label: ', style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontSize: 11)),
|
||||||
Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)),
|
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(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
Expanded(
|
||||||
Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 13)),
|
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 s = data.summary;
|
||||||
final samples = data.samples;
|
final samples = data.samples;
|
||||||
final totalSamples = samples.length;
|
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);
|
final expectedSamples = (dur.inSeconds * 20);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: const Color(0xFF161A22),
|
color: colors.surfaceContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(height: 16),
|
||||||
_buildSubSectionTitle('GPS Receiver'),
|
_buildSubSectionTitle(context, 'GPS Receiver'),
|
||||||
_buildDiagItem('Fixes Recorded', '${s?.gpsFixCount ?? samples.where((x) => x.latitude != null).length}'),
|
_buildDiagItem(context, 'GPS Fix Samples', '${s?.gpsFixCount ?? 0} / $totalSamples'),
|
||||||
_buildDiagItem('Average Accuracy', '${s?.averageGpsAccuracy?.toStringAsFixed(1) ?? '4.1'} m'),
|
_buildDiagItem(context, 'Stale Fix Discards', '${s?.gpsStaleEventCount ?? 0}'),
|
||||||
_buildDiagItem('Worst Accuracy', '${s?.worstGpsAccuracy?.toStringAsFixed(1) ?? '12.4'} m'),
|
_buildDiagItem(context, 'Position Jump Rejections', '${s?.gpsJumpRejectCount ?? 0}'),
|
||||||
_buildDiagItem('Max GPS Age', '${s?.maxGpsAgeMs ?? 150} ms'),
|
|
||||||
_buildDiagItem('GPS Stale Events', '${s?.gpsStaleEventCount ?? 0}'),
|
|
||||||
_buildDiagItem('Rejected GPS Jumps', '${s?.gpsJumpRejectCount ?? 0}'),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildSubSectionTitle('IMU & Motion Pipeline'),
|
_buildSubSectionTitle(context, 'IMU & Sensor Stream'),
|
||||||
_buildDiagItem('IMU Samples (~50Hz)', '${totalSamples > 0 ? (totalSamples * 2.5).floor() : 0}'),
|
_buildDiagItem(context, 'Hardware IMU Clips / Saturation', '${s?.imuSaturationCount ?? 0}'),
|
||||||
_buildDiagItem('Saturation Events (>120 m/s²)', '${s?.imuSaturationCount ?? 0}'),
|
_buildDiagItem(context, 'Rapid Accel Events (>0.3g)', '${s?.accelerationEventCount ?? 0}'),
|
||||||
_buildDiagItem('Max Longitudinal G', '${s?.maxLongitudinalG?.toStringAsFixed(2) ?? '0.00'} g'),
|
_buildDiagItem(context, 'Rapid Braking Events (<-0.4g)', '${s?.brakingEventCount ?? 0}'),
|
||||||
_buildDiagItem('Max Lateral G', '${s?.maxLateralG?.toStringAsFixed(2) ?? '0.00'} g'),
|
|
||||||
_buildDiagItem('Max Lean Angle', '${s?.maxLeanAngle?.toStringAsFixed(1) ?? '0.0'}°'),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildSubSectionTitle('Telemetry Heartbeat (20Hz)'),
|
_buildSubSectionTitle(context, 'Database & Pipeline Performance'),
|
||||||
_buildDiagItem('Expected Samples', '$expectedSamples'),
|
_buildDiagItem(context, 'Total Pipeline Samples', '${s?.totalSamples ?? 0}'),
|
||||||
_buildDiagItem('Received Samples', '$totalSamples'),
|
_buildDiagItem(context, 'Expected Samples (@20Hz)', '$expectedSamples'),
|
||||||
_buildDiagItem('Valid Samples', '${s?.validSamples ?? totalSamples}'),
|
_buildDiagItem(context, 'Samples Written to DB', '${s?.databaseSamplesWritten ?? 0}'),
|
||||||
_buildDiagItem('Suspect Samples', '${s?.suspectSamples ?? 0}'),
|
_buildDiagItem(context, 'Samples Dropped / Lost', '${s?.databaseSamplesLost ?? 0}'),
|
||||||
_buildDiagItem('Rejected Samples', '${s?.rejectedSamples ?? 0}'),
|
_buildDiagItem(context, 'Suspect Samples', '${s?.suspectSamples ?? 0}'),
|
||||||
_buildDiagItem('Largest Gap', '${s?.maxTelemetryGapMs ?? 50} ms'),
|
_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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0, top: 4.0),
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 3.0),
|
padding: const EdgeInsets.symmetric(vertical: 3.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(color: Colors.white60, fontSize: 13)),
|
Expanded(
|
||||||
Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13)),
|
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 runs = data.perfRuns;
|
||||||
final zeroTo60 = runs.where((r) => r.type == '0-60').toList();
|
final zeroTo60 = runs.where((r) => r.type == '0-60').toList();
|
||||||
final zeroTo100 = runs.where((r) => r.type == '0-100').toList();
|
final zeroTo100 = runs.where((r) => r.type == '0-100').toList();
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: const Color(0xFF161A22),
|
color: colors.surfaceContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(height: 16),
|
||||||
_buildSubSectionTitle('0–60 MPH Sprint'),
|
_buildSubSectionTitle(context, '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(
|
||||||
_buildDiagItem('Total Runs', '${zeroTo60.length}'),
|
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),
|
const SizedBox(height: 16),
|
||||||
_buildSubSectionTitle('0–100 MPH Sprint'),
|
_buildSubSectionTitle(context, '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(
|
||||||
_buildDiagItem('Total Runs', '${zeroTo100.length}'),
|
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) ...[
|
if (runs.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
...runs.map((r) => ExpansionTile(
|
...runs.map((r) => Theme(
|
||||||
title: Text('${r.type} Run: ${r.timeMs.toStringAsFixed(2)}s', style: const TextStyle(color: Colors.white, fontSize: 13)),
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
subtitle: Text('Top Speed: ${r.topSpeedReached.toStringAsFixed(1)} MPH', style: const TextStyle(color: Colors.cyanAccent, fontSize: 11)),
|
child: ExpansionTile(
|
||||||
children: [
|
title: Text(
|
||||||
Padding(
|
'${r.type} Run: ${r.timeMs.toStringAsFixed(2)}s',
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
style: TextStyle(color: theme.colorScheme.onSurface, fontSize: 13),
|
||||||
child: Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
subtitle: Text(
|
||||||
|
'Top Speed: ${r.topSpeedReached.toStringAsFixed(1)} MPH',
|
||||||
|
style: TextStyle(color: colors.cyberCyan, fontSize: 11),
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
Text('Timestamp: ${r.timestamp.toLocal()}', style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
Padding(
|
||||||
Text('Top Speed: ${r.topSpeedReached.toStringAsFixed(1)} MPH', style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
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 filteredEvents = data.events.where((e) {
|
||||||
final type = e.eventType;
|
final type = e.eventType;
|
||||||
final sev = e.severity;
|
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 == 'Performance') return type.contains('performance') || type.contains('acceleration') || type.contains('braking');
|
||||||
if (_activeFilter == 'Database') return type.contains('database');
|
if (_activeFilter == 'Database') return type.contains('database');
|
||||||
if (_activeFilter == 'Errors/Warnings') {
|
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;
|
return true;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: const Color(0xFF161A22),
|
color: colors.surfaceContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Colors.white12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(height: 12),
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
|
@ -416,10 +652,20 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 6.0),
|
padding: const EdgeInsets.only(right: 6.0),
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
label: Text(f, style: TextStyle(color: isSelected ? Colors.black : Colors.white, fontSize: 11)),
|
label: Text(
|
||||||
selectedColor: Colors.cyanAccent,
|
f,
|
||||||
backgroundColor: const Color(0xFF1E222D),
|
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: (_) {
|
onSelected: (_) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_activeFilter = f;
|
_activeFilter = f;
|
||||||
|
|
@ -432,20 +678,26 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (filteredEvents.isEmpty)
|
if (filteredEvents.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text('No events matching selected filter.', style: TextStyle(color: Colors.white38, fontSize: 13)),
|
child: Text(
|
||||||
|
'No events matching selected filter.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ListView.separated(
|
ListView.separated(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: filteredEvents.length,
|
itemCount: filteredEvents.length,
|
||||||
separatorBuilder: (_, _) => const Divider(color: Colors.white10, height: 16),
|
separatorBuilder: (_, _) => Divider(color: colors.outlineVariant, height: 16),
|
||||||
itemBuilder: (context, idx) {
|
itemBuilder: (context, idx) {
|
||||||
final e = filteredEvents[idx];
|
final e = filteredEvents[idx];
|
||||||
final timeStr = e.timestamp.toLocal().toString().substring(11, 23);
|
final timeStr = e.timestamp.toLocal().toString().substring(11, 23);
|
||||||
final sevColor = _getSeverityColor(e.severity);
|
final sevColor = _getSeverityColor(context, e.severity);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -453,26 +705,71 @@ class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(color: sevColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4)),
|
decoration: BoxDecoration(
|
||||||
child: Text(e.severity, style: TextStyle(color: sevColor, fontSize: 9, fontWeight: FontWeight.bold)),
|
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),
|
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),
|
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) ...[
|
if (e.speed != null || e.gpsAgeMs != null || e.longitudinalG != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
if (e.speed != null) Text('${e.speed!.toStringAsFixed(1)} MPH', style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
if (e.speed != null)
|
||||||
if (e.gpsAgeMs != null) Text('Age: ${e.gpsAgeMs}ms', style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
Text(
|
||||||
if (e.longitudinalG != null) Text('${e.longitudinalG!.toStringAsFixed(2)}g', style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
'${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()) {
|
switch (severity.toUpperCase()) {
|
||||||
case 'INFO': return Colors.cyanAccent;
|
case 'INFO':
|
||||||
case 'WARNING': return Colors.amberAccent;
|
return colors.cyberCyan;
|
||||||
case 'ERROR': return Colors.orangeAccent;
|
case 'WARNING':
|
||||||
case 'CRITICAL': return Colors.redAccent;
|
return colors.warningAmber;
|
||||||
default: return Colors.white70;
|
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);
|
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 {
|
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