fix(telemetry): solve sensor fusion false lean, 3D G-force projection, stationary speed hysteresis, and factory motorcycle specs
This commit is contained in:
parent
51d31211f0
commit
f7fbe71d0d
|
|
@ -43,3 +43,4 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
*.apk
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Permissions for GPS and Sensors -->
|
||||
<!-- Permissions for GPS, Wakelock, Network, and Android 13/14+ Lifecycle -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:label="motorcycle_dash"
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@ import '../features/garage/add_motorcycle_screen.dart';
|
|||
import '../features/garage/motorcycle_detail_screen.dart';
|
||||
import 'widgets/main_layout.dart';
|
||||
|
||||
import '../features/settings/field_test_report_screen.dart';
|
||||
import '../features/settings/field_test_live_log_screen.dart';
|
||||
|
||||
// ignore: unused_element
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
final appRouter = GoRouter(
|
||||
navigatorKey: _rootNavigatorKey,
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
StatefulShellRoute.indexedStack(
|
||||
|
|
@ -85,6 +88,19 @@ final appRouter = GoRouter(
|
|||
path: 'diagnostics',
|
||||
builder: (context, state) => const DiagnosticsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'field-test-report',
|
||||
builder: (context, state) {
|
||||
final sessionId = state.uri.queryParameters['sessionId'] != null
|
||||
? int.tryParse(state.uri.queryParameters['sessionId']!)
|
||||
: null;
|
||||
return FieldTestReportScreen(sessionId: sessionId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'field-test-live-log',
|
||||
builder: (context, state) => const FieldTestLiveLogScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class AppTheme {
|
|||
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const Color(0xFF00E5FF).withOpacity(0.2); // Cyan with opacity for selected
|
||||
return const Color(0xFF00E5FF).withValues(alpha: 0.2); // Cyan with opacity for selected
|
||||
}
|
||||
return null; // Use default for unselected
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,28 +3,50 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../features/rides/ride_state_machine.dart';
|
||||
|
||||
class MainLayout extends ConsumerWidget {
|
||||
import '../../services/location_service.dart';
|
||||
|
||||
class MainLayout extends ConsumerStatefulWidget {
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
const MainLayout({super.key, required this.navigationShell});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<MainLayout> createState() => _MainLayoutState();
|
||||
}
|
||||
|
||||
class _MainLayoutState extends ConsumerState<MainLayout> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_requestPermissions();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
try {
|
||||
final locationService = ref.read(locationServiceProvider);
|
||||
await locationService.requestPermissions();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<RideSession>(rideStateMachineProvider, (previous, next) {
|
||||
if (previous?.state != RideState.riding && next.state == RideState.riding) {
|
||||
if (navigationShell.currentIndex != 0) {
|
||||
navigationShell.goBranch(0);
|
||||
if (widget.navigationShell.currentIndex != 0) {
|
||||
widget.navigationShell.goBranch(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
body: widget.navigationShell,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: navigationShell.currentIndex,
|
||||
currentIndex: widget.navigationShell.currentIndex,
|
||||
onTap: (index) {
|
||||
navigationShell.goBranch(
|
||||
widget.navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
initialLocation: index == widget.navigationShell.currentIndex,
|
||||
);
|
||||
},
|
||||
selectedItemColor: Theme.of(context).colorScheme.primary,
|
||||
|
|
|
|||
|
|
@ -9,12 +9,23 @@ import 'tables.dart';
|
|||
|
||||
part 'database.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [Rides, PerformanceRuns, TelemetrySamples, RideEvents, GarageMotorcycles])
|
||||
@DriftDatabase(tables: [
|
||||
Rides,
|
||||
PerformanceRuns,
|
||||
TelemetrySamples,
|
||||
RideEvents,
|
||||
GarageMotorcycles,
|
||||
FieldTestSessions,
|
||||
FieldTestEvents,
|
||||
FieldTestGpsRecords,
|
||||
FieldTestImuRecords,
|
||||
])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
AppDatabase.forTesting(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
|
|
@ -45,6 +56,12 @@ class AppDatabase extends _$AppDatabase {
|
|||
await m.addColumn(rides, rides.motorcycleModel);
|
||||
await m.addColumn(rides, rides.motorcycleVariant);
|
||||
}
|
||||
if (from < 4) {
|
||||
await m.createTable(fieldTestSessions);
|
||||
await m.createTable(fieldTestEvents);
|
||||
await m.createTable(fieldTestGpsRecords);
|
||||
await m.createTable(fieldTestImuRecords);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -85,3 +85,69 @@ class GarageMotorcycles extends Table {
|
|||
DateTimeColumn get lastUpdated => dateTime()();
|
||||
TextColumn get specificationJson => text().nullable()();
|
||||
}
|
||||
|
||||
class FieldTestSessions extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get rideId => integer().nullable().references(Rides, #id)();
|
||||
DateTimeColumn get startedAt => dateTime()();
|
||||
DateTimeColumn get endedAt => dateTime().nullable()();
|
||||
TextColumn get appVersion => text().withDefault(const Constant('1.0.0'))();
|
||||
TextColumn get buildNumber => text().withDefault(const Constant('1'))();
|
||||
TextColumn get androidVersion => text().withDefault(const Constant('Android'))();
|
||||
TextColumn get deviceModel => text().withDefault(const Constant('Generic'))();
|
||||
IntColumn get motorcycleId => integer().nullable()();
|
||||
IntColumn get motorcycleYear => integer().nullable()();
|
||||
TextColumn get motorcycleMake => text().nullable()();
|
||||
TextColumn get motorcycleModel => text().nullable()();
|
||||
RealColumn get calibrationRollOffset => real().withDefault(const Constant(0.0))();
|
||||
RealColumn get calibrationPitchOffset => real().withDefault(const Constant(0.0))();
|
||||
IntColumn get totalEvents => integer().withDefault(const Constant(0))();
|
||||
IntColumn get anomalyCount => integer().withDefault(const Constant(0))();
|
||||
BoolColumn get completed => boolean().withDefault(const Constant(false))();
|
||||
TextColumn get healthSummaryJson => text().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
}
|
||||
|
||||
class FieldTestEvents extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get sessionId => integer().references(FieldTestSessions, #id)();
|
||||
DateTimeColumn get timestamp => dateTime()();
|
||||
TextColumn get eventType => text()();
|
||||
TextColumn get severity => text()();
|
||||
TextColumn get message => text()();
|
||||
TextColumn get dataJson => text().nullable()();
|
||||
}
|
||||
|
||||
class FieldTestGpsRecords extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get sessionId => integer().references(FieldTestSessions, #id)();
|
||||
DateTimeColumn get timestamp => dateTime()();
|
||||
RealColumn get latitude => real()();
|
||||
RealColumn get longitude => real()();
|
||||
RealColumn get altitude => real().nullable()();
|
||||
RealColumn get rawSpeed => real().nullable()();
|
||||
RealColumn get filteredSpeed => real().nullable()();
|
||||
RealColumn get heading => real().nullable()();
|
||||
RealColumn get accuracy => real().nullable()();
|
||||
TextColumn get gpsHealth => text()();
|
||||
BoolColumn get accepted => boolean()();
|
||||
TextColumn get rejectionReason => text().nullable()();
|
||||
}
|
||||
|
||||
class FieldTestImuRecords extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get sessionId => integer().references(FieldTestSessions, #id)();
|
||||
DateTimeColumn get timestamp => dateTime()();
|
||||
RealColumn get accelerometerX => real().nullable()();
|
||||
RealColumn get accelerometerY => real().nullable()();
|
||||
RealColumn get accelerometerZ => real().nullable()();
|
||||
RealColumn get gyroX => real().nullable()();
|
||||
RealColumn get gyroY => real().nullable()();
|
||||
RealColumn get gyroZ => real().nullable()();
|
||||
RealColumn get roll => real().nullable()();
|
||||
RealColumn get pitch => real().nullable()();
|
||||
RealColumn get longitudinalG => real().nullable()();
|
||||
RealColumn get lateralG => real().nullable()();
|
||||
RealColumn get filteredLongitudinalG => real().nullable()();
|
||||
RealColumn get filteredLateralG => real().nullable()();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import 'widgets/lean_visualizer.dart';
|
|||
import '../../services/alerts/road_alert_models.dart';
|
||||
import '../../services/alerts/road_alert_service.dart';
|
||||
|
||||
import '../../services/location_service.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
|
|
@ -19,6 +21,8 @@ class DashboardScreen extends ConsumerWidget {
|
|||
final rideSession = ref.watch(rideStateMachineProvider);
|
||||
final layoutMode = ref.watch(dashboardConfigProvider);
|
||||
final alertsAsync = ref.watch(activeAlertsStreamProvider);
|
||||
final hasLocPermissionAsync = ref.watch(locationPermissionGrantedProvider);
|
||||
final hasLocPermission = hasLocPermissionAsync.value ?? true;
|
||||
|
||||
final activeAlerts = alertsAsync.value ?? [];
|
||||
final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null;
|
||||
|
|
@ -43,6 +47,7 @@ class DashboardScreen extends ConsumerWidget {
|
|||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
if (!hasLocPermission) _buildPermissionBanner(context, ref),
|
||||
if (closestAlert != null) _buildAlertBanner(closestAlert),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
|
|
@ -53,13 +58,13 @@ class DashboardScreen extends ConsumerWidget {
|
|||
return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref);
|
||||
}
|
||||
if (layoutMode == DashboardLayout.touring) {
|
||||
return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref);
|
||||
return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref, telemetry);
|
||||
}
|
||||
|
||||
if (isLandscape) {
|
||||
return _buildLandscapeLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref);
|
||||
return _buildLandscapeLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref, telemetry);
|
||||
}
|
||||
return _buildPortraitLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref);
|
||||
return _buildPortraitLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref, telemetry);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -102,13 +107,13 @@ class DashboardScreen extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildPortraitLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref) {
|
||||
Widget _buildPortraitLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref, TelemetrySample? telemetry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildTopBar(context, heading, session, ref),
|
||||
_buildTopBar(context, heading, session, ref, telemetry),
|
||||
const Spacer(flex: 1),
|
||||
_buildCenterSpeedometer(context, speed),
|
||||
const Spacer(flex: 1),
|
||||
|
|
@ -128,7 +133,7 @@ class DashboardScreen extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildLandscapeLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref) {
|
||||
Widget _buildLandscapeLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref, TelemetrySample? telemetry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
child: Row(
|
||||
|
|
@ -151,7 +156,7 @@ class DashboardScreen extends ConsumerWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTopBar(context, heading, session, ref),
|
||||
_buildTopBar(context, heading, session, ref, telemetry),
|
||||
_buildCenterSpeedometer(context, speed),
|
||||
_buildActionButtons(context, session),
|
||||
],
|
||||
|
|
@ -174,8 +179,23 @@ class DashboardScreen extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar(BuildContext context, double heading, RideSession session, WidgetRef ref) {
|
||||
Widget _buildTopBar(BuildContext context, double heading, RideSession session, WidgetRef ref, TelemetrySample? telemetry) {
|
||||
final headingStr = CompassService.getHeadingString(heading);
|
||||
final gpsAcc = telemetry?.gpsAccuracy;
|
||||
IconData gpsIcon = Icons.gps_off;
|
||||
Color gpsColor = Colors.redAccent;
|
||||
if (gpsAcc != null) {
|
||||
if (gpsAcc <= 15.0) {
|
||||
gpsIcon = Icons.gps_fixed;
|
||||
gpsColor = Colors.greenAccent;
|
||||
} else if (gpsAcc <= 35.0) {
|
||||
gpsIcon = Icons.gps_fixed;
|
||||
gpsColor = Colors.amberAccent;
|
||||
} else {
|
||||
gpsIcon = Icons.gps_not_fixed;
|
||||
gpsColor = Colors.redAccent;
|
||||
}
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
@ -183,6 +203,8 @@ class DashboardScreen extends ConsumerWidget {
|
|||
Row(
|
||||
children: [
|
||||
_buildLayoutSwitcher(ref),
|
||||
const SizedBox(width: 6),
|
||||
Icon(gpsIcon, color: gpsColor, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
headingStr,
|
||||
|
|
@ -365,12 +387,12 @@ class DashboardScreen extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTouringLayout(BuildContext context, double speed, double heading, RideSession session, WidgetRef ref) {
|
||||
Widget _buildTouringLayout(BuildContext context, double speed, double heading, RideSession session, WidgetRef ref, TelemetrySample? telemetry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTopBar(context, heading, session, ref),
|
||||
_buildTopBar(context, heading, session, ref, telemetry),
|
||||
const Spacer(),
|
||||
_buildCenterSpeedometer(context, speed),
|
||||
const Spacer(),
|
||||
|
|
@ -393,4 +415,40 @@ class DashboardScreen extends ConsumerWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPermissionBanner(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Colors.amber.shade900.withValues(alpha: 0.95),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_off, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Location permission needed for GPS speedometer.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () async {
|
||||
final locService = ref.read(locationServiceProvider);
|
||||
await locService.requestPermissions();
|
||||
ref.invalidate(locationPermissionGrantedProvider);
|
||||
},
|
||||
child: const Text('Enable', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../rides/ride_state_machine.dart';
|
||||
|
||||
|
|
@ -10,47 +11,130 @@ class GForceVisualizer extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double dialRadius = 42.0;
|
||||
|
||||
// Calculate clamped pixel offset for the dot (1.0G = dial edge)
|
||||
double targetX = (lateralG * dialRadius).clamp(-dialRadius, dialRadius);
|
||||
double targetY = (-accelG * dialRadius).clamp(-dialRadius, dialRadius);
|
||||
|
||||
// Limit magnitude to dial boundary
|
||||
final dist = math.sqrt(targetX * targetX + targetY * targetY);
|
||||
if (dist > dialRadius) {
|
||||
targetX = (targetX / dist) * dialRadius;
|
||||
targetY = (targetY / dist) * dialRadius;
|
||||
}
|
||||
|
||||
// Dynamic dot color based on load
|
||||
Color dotColor = Colors.cyanAccent;
|
||||
if (accelG < -0.25) {
|
||||
dotColor = Colors.redAccent; // Heavy Braking
|
||||
} else if (accelG > 0.25) {
|
||||
dotColor = Colors.greenAccent; // Hard Acceleration
|
||||
} else if (lateralG.abs() > 0.3) {
|
||||
dotColor = Colors.amberAccent; // Heavy Cornering
|
||||
}
|
||||
|
||||
// Current live longitudinal value
|
||||
final liveText = accelG.abs() < 0.05
|
||||
? '0.00 G'
|
||||
: (accelG > 0 ? '+${accelG.toStringAsFixed(2)} G' : '${accelG.toStringAsFixed(2)} G');
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const Text('G-FORCE', style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 2)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('G-FORCE', style: TextStyle(color: Colors.white54, fontSize: 11, letterSpacing: 2, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
width: 104,
|
||||
height: 104,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white24, width: 2),
|
||||
border: Border.all(color: Colors.white24, width: 1.5),
|
||||
color: const Color(0xFF111111),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: dotColor.withValues(alpha: 0.15),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(width: 2, height: 100, color: Colors.white10),
|
||||
Container(width: 100, height: 2, color: Colors.white10),
|
||||
Transform.translate(
|
||||
offset: Offset(lateralG * 40, -accelG * 40),
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
// Concentric reference rings (0.33G and 0.66G)
|
||||
Container(
|
||||
width: 35,
|
||||
height: 35,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Theme.of(context).colorScheme.primary, blurRadius: 8)],
|
||||
border: Border.all(color: Colors.white10, width: 1),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white12, width: 1),
|
||||
),
|
||||
),
|
||||
// Crosshairs
|
||||
Container(width: 1, height: 104, color: Colors.white12),
|
||||
Container(width: 104, height: 1, color: Colors.white12),
|
||||
// Center deadband indicator
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white24,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
// Smoothly animated G-Force tracking dot
|
||||
TweenAnimationBuilder<Offset>(
|
||||
tween: Tween<Offset>(begin: Offset.zero, end: Offset(targetX, targetY)),
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, offset, child) {
|
||||
return Transform.translate(
|
||||
offset: offset,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: dotColor.withValues(alpha: 0.8), blurRadius: 10, spreadRadius: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
// Live G-force readout
|
||||
Text(
|
||||
liveText,
|
||||
style: TextStyle(
|
||||
color: dotColor,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('BRK: ${session.maxBrakingG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
|
||||
const SizedBox(width: 12),
|
||||
Text('ACC: ${session.maxAccelG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.greenAccent, fontSize: 12)),
|
||||
Text('BRK: ${session.maxBrakingG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.redAccent, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 10),
|
||||
Text('ACC: ${session.maxAccelG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.greenAccent, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -85,9 +85,10 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Ride ${ride.id}',
|
||||
|
|
@ -95,8 +96,9 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
|||
),
|
||||
if (bikeName != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.cyanAccent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
|
@ -104,15 +106,20 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
|||
),
|
||||
child: Text(
|
||||
bikeName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
ride.startTime.toLocal().toString().split('.')[0],
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
|
||||
static const double _speedThresholdMph = 1.0;
|
||||
static const double _abortThresholdMph = 0.5;
|
||||
static const double _minLaunchGForce = 0.2; // IMU validation for start
|
||||
|
||||
PerformanceTimerService(this._db, Stream<TelemetrySample> telemetryStream) : super(PerformanceStateData()) {
|
||||
_telemetrySub = telemetryStream.listen(_onTelemetryUpdate);
|
||||
|
|
@ -71,13 +70,14 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
if (sample.speed == null) return;
|
||||
|
||||
final speedMph = sample.speed!;
|
||||
final accelG = sample.longitudinalG ?? 0.0;
|
||||
|
||||
if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) {
|
||||
if (speedMph < _speedThresholdMph) {
|
||||
if (state.state != PerformanceState.ready) {
|
||||
state = state.copyWith(state: PerformanceState.ready);
|
||||
} else if (speedMph >= _speedThresholdMph && accelG >= _minLaunchGForce) {
|
||||
// Start timing ONLY if speed breaks threshold AND we detect forward G-force (IMU Validation)
|
||||
}
|
||||
} else if (speedMph >= _speedThresholdMph) {
|
||||
// Start timing immediately when motorcycle launches past 1.0 MPH
|
||||
// Interpolate the exact start time between the last frame (stopped) and this frame
|
||||
_startTime = _interpolateTime(
|
||||
_lastSample?.speed ?? 0.0,
|
||||
|
|
|
|||
|
|
@ -134,12 +134,19 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
),
|
||||
);
|
||||
|
||||
final polyline = AppPolyline(
|
||||
final polylines = _routePoints.length >= 2
|
||||
? [
|
||||
AppPolyline(
|
||||
id: 'replay_route',
|
||||
points: _routePoints,
|
||||
color: Colors.cyanAccent,
|
||||
width: 5.0,
|
||||
);
|
||||
)
|
||||
]
|
||||
: <AppPolyline>[];
|
||||
|
||||
final maxSliderValue = _telemetry.length > 1 ? (_telemetry.length - 1).toDouble() : 1.0;
|
||||
final currentSliderValue = _telemetry.length > 1 ? _currentIndex.toDouble().clamp(0.0, maxSliderValue) : 0.0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')),
|
||||
|
|
@ -154,7 +161,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
center: currentPos,
|
||||
zoom: 17.0,
|
||||
markers: [riderMarker],
|
||||
polylines: [polyline],
|
||||
polylines: polylines,
|
||||
),
|
||||
// Telemetry Overlay
|
||||
Positioned(
|
||||
|
|
@ -184,14 +191,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Slider(
|
||||
value: _currentIndex.toDouble(),
|
||||
value: currentSliderValue,
|
||||
min: 0,
|
||||
max: (_telemetry.length - 1).toDouble(),
|
||||
onChanged: (val) {
|
||||
max: maxSliderValue,
|
||||
onChanged: _telemetry.length > 1
|
||||
? (val) {
|
||||
setState(() {
|
||||
_currentIndex = val.toInt();
|
||||
});
|
||||
},
|
||||
}
|
||||
: null,
|
||||
activeColor: Colors.cyanAccent,
|
||||
),
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import '../../services/telemetry_service.dart';
|
|||
import '../../services/audio_alert_service.dart';
|
||||
import '../records/records_service.dart';
|
||||
import '../garage/garage_providers.dart';
|
||||
import '../../services/field_test/field_test_logger.dart';
|
||||
|
||||
enum RideState { idle, ready, riding, paused, finished }
|
||||
|
||||
|
|
@ -107,6 +108,44 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
|
||||
// Write to DB every 5 seconds
|
||||
_batchWriteTimer = Timer.periodic(const Duration(seconds: 5), (_) => _flushBatch());
|
||||
|
||||
// Recover any unfinished rides from previous sessions
|
||||
_recoverInterruptedRides();
|
||||
}
|
||||
|
||||
Future<void> _recoverInterruptedRides() async {
|
||||
try {
|
||||
final unclosedRides = await (db.select(db.rides)
|
||||
..where((r) => r.endTime.isNull())
|
||||
).get();
|
||||
|
||||
if (unclosedRides.isNotEmpty) {
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).logInterruptedRideFound(unclosedRides.length);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
for (final r in unclosedRides) {
|
||||
// Find last recorded telemetry sample for this ride to determine actual end time
|
||||
final lastSample = await (db.select(db.telemetrySamples)
|
||||
..where((t) => t.rideId.equals(r.id))
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp, mode: drift.OrderingMode.desc)])
|
||||
..limit(1)
|
||||
).getSingleOrNull();
|
||||
|
||||
final calculatedEndTime = lastSample?.timestamp ?? r.startTime.add(Duration(seconds: r.movingTime + r.stoppedTime));
|
||||
|
||||
await (db.update(db.rides)..where((t) => t.id.equals(r.id))).write(
|
||||
RidesCompanion(
|
||||
endTime: drift.Value(calculatedEndTime),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).logRideRecovered(r.id);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void setReady() {
|
||||
|
|
@ -138,17 +177,34 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
_accumulatedDistance = 0;
|
||||
WakelockPlus.enable();
|
||||
audioAlertService.speak('Session started');
|
||||
|
||||
// Auto-start Black-Box Field Test Session if enabled
|
||||
try {
|
||||
final isFieldTestEnabled = ref.read(fieldTestModeEnabledProvider);
|
||||
if (isFieldTestEnabled) {
|
||||
await ref.read(fieldTestLoggerProvider).startSession(
|
||||
rideId: id,
|
||||
bike: defaultBike,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void pauseRide() {
|
||||
state = state.copyWith(state: RideState.paused);
|
||||
WakelockPlus.disable();
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).logLifecycle('Ride Paused');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void resumeRide() {
|
||||
state = state.copyWith(state: RideState.riding);
|
||||
_lastSample = null; // reset to avoid jumping
|
||||
WakelockPlus.enable();
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).logLifecycle('Ride Resumed');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> finishRide() async {
|
||||
|
|
@ -180,13 +236,20 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
maxBrakingG: state.maxBrakingG,
|
||||
longestRideMiles: state.tripDistanceMiles,
|
||||
greatestElevationGain: state.elevationGain,
|
||||
// Will hook into PerformanceTimerService for 0-60 later
|
||||
);
|
||||
|
||||
await recordsService.checkAndSaveRecords(runStats, (recordName, value) {
|
||||
audioAlertService.speak('New record: $recordName');
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-stop Black-Box Field Test Session if active
|
||||
try {
|
||||
if (ref.read(fieldTestLoggerProvider).isSessionActive) {
|
||||
await ref.read(fieldTestLoggerProvider).stopSession();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
state = state.copyWith(state: RideState.finished);
|
||||
WakelockPlus.disable();
|
||||
audioAlertService.speak('Session ended');
|
||||
|
|
@ -228,8 +291,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
}
|
||||
|
||||
Future<void> _flushBatch() async {
|
||||
if (_telemetryBatch.isEmpty) return;
|
||||
|
||||
if (_telemetryBatch.isNotEmpty) {
|
||||
final batchToInsert = List<TelemetrySamplesCompanion>.from(_telemetryBatch);
|
||||
_telemetryBatch.clear();
|
||||
|
||||
|
|
@ -238,6 +300,28 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
});
|
||||
}
|
||||
|
||||
// Incrementally persist ongoing ride summary stats to SQLite so crashes/terminations never lose stats
|
||||
if (state.dbId != null && state.state == RideState.riding) {
|
||||
try {
|
||||
await (db.update(db.rides)..where((t) => t.id.equals(state.dbId!))).write(
|
||||
RidesCompanion(
|
||||
distanceMiles: drift.Value(state.tripDistanceMiles),
|
||||
topSpeedMph: drift.Value(state.topSpeedMph),
|
||||
maxLeanLeft: drift.Value(state.maxLeanLeft),
|
||||
maxLeanRight: drift.Value(state.maxLeanRight),
|
||||
maxAccelG: drift.Value(state.maxAccelG),
|
||||
maxBrakingG: drift.Value(state.maxBrakingG),
|
||||
movingTime: drift.Value(state.movingTimeSeconds),
|
||||
stoppedTime: drift.Value(state.stoppedTimeSeconds),
|
||||
averageSpeed: drift.Value(state.averageSpeedMph),
|
||||
elevationGain: drift.Value(state.elevationGain),
|
||||
elevationLoss: drift.Value(state.elevationLoss),
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _processStats(TelemetrySample sample) {
|
||||
double newTopSpeed = state.topSpeedMph;
|
||||
double newMaxLeanLeft = state.maxLeanLeft;
|
||||
|
|
@ -277,7 +361,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
newStoppedTime = internalStopped.floor();
|
||||
}
|
||||
|
||||
if (sample.leanAngle != null) {
|
||||
if (sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) {
|
||||
if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) {
|
||||
newMaxLeanLeft = sample.leanAngle!.abs();
|
||||
} else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) {
|
||||
|
|
@ -285,7 +369,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
}
|
||||
}
|
||||
|
||||
if (sample.longitudinalG != null) {
|
||||
if (sample.longitudinalG != null && sample.longitudinalG!.abs() <= 2.5) {
|
||||
if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) {
|
||||
newMaxAccelG = sample.longitudinalG!;
|
||||
} else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,22 @@ class DiagnosticsScreen extends ConsumerWidget {
|
|||
final telemetryAsync = ref.watch(telemetryStreamProvider);
|
||||
final t = telemetryAsync.value;
|
||||
|
||||
final gpsAccuracy = t?.gpsAccuracy;
|
||||
String gpsHealthText = 'No Fix (Unavailable)';
|
||||
Color gpsHealthColor = Colors.redAccent;
|
||||
if (gpsAccuracy != null) {
|
||||
if (gpsAccuracy <= 15.0) {
|
||||
gpsHealthText = 'Healthy (${gpsAccuracy.toStringAsFixed(1)}m)';
|
||||
gpsHealthColor = Colors.greenAccent;
|
||||
} else if (gpsAccuracy <= 35.0) {
|
||||
gpsHealthText = 'Degraded (${gpsAccuracy.toStringAsFixed(1)}m)';
|
||||
gpsHealthColor = Colors.amberAccent;
|
||||
} else {
|
||||
gpsHealthText = 'Poor (${gpsAccuracy.toStringAsFixed(1)}m)';
|
||||
gpsHealthColor = Colors.redAccent;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sensor Diagnostics')),
|
||||
body: t == null
|
||||
|
|
@ -19,21 +35,46 @@ class DiagnosticsScreen extends ConsumerWidget {
|
|||
children: [
|
||||
_buildMetric('Timestamp', t.timestamp.toIso8601String()),
|
||||
const Divider(),
|
||||
const Text('GPS Data', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||
_buildMetric('Latitude', t.latitude?.toString() ?? 'N/A'),
|
||||
_buildMetric('Longitude', t.longitude?.toString() ?? 'N/A'),
|
||||
_buildMetric('Altitude', t.altitude?.toString() ?? 'N/A'),
|
||||
_buildMetric('Speed (m/s)', t.speed?.toString() ?? 'N/A'),
|
||||
_buildMetric('Heading', t.heading?.toString() ?? 'N/A'),
|
||||
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('IMU Data', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||
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?.toStringAsFixed(3) ?? 'N/A'),
|
||||
_buildMetric('Lateral G', t.lateralG?.toStringAsFixed(3) ?? 'N/A'),
|
||||
_buildMetric('Lean Angle', t.leanAngle?.toStringAsFixed(2) ?? 'N/A'),
|
||||
_buildMetric('Pitch Angle', t.pitchAngle?.toStringAsFixed(2) ?? '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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.white70)),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.circle, color: statusColor, size: 10),
|
||||
const SizedBox(width: 6),
|
||||
Text(status, style: TextStyle(fontWeight: FontWeight.bold, color: statusColor)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../services/field_test/field_test_logger.dart';
|
||||
import '../../services/field_test/field_test_models.dart';
|
||||
|
||||
class FieldTestLiveLogScreen extends ConsumerStatefulWidget {
|
||||
const FieldTestLiveLogScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FieldTestLiveLogScreen> createState() => _FieldTestLiveLogScreenState();
|
||||
}
|
||||
|
||||
class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen> {
|
||||
String _selectedFilter = 'All';
|
||||
|
||||
final List<String> _filters = [
|
||||
'All',
|
||||
'Errors',
|
||||
'GPS',
|
||||
'Sensors',
|
||||
'Persistence',
|
||||
'Alerts',
|
||||
'Lifecycle',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final logger = ref.watch(fieldTestLoggerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Black-Box Live Log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => setState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildFilterBar(),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<FieldTestEventItem>>(
|
||||
stream: logger.liveLogStream,
|
||||
initialData: logger.liveLogs,
|
||||
builder: (context, snapshot) {
|
||||
final logs = (snapshot.data ?? logger.liveLogs).reversed.toList();
|
||||
final filtered = logs.where(_matchesFilter).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No log entries matching filter.', style: TextStyle(color: Colors.white60)),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = filtered[index];
|
||||
return _buildLogCard(item);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterBar() {
|
||||
return Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: Colors.black26,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _filters.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final f = _filters[index];
|
||||
final isSelected = f == _selectedFilter;
|
||||
return ChoiceChip(
|
||||
label: Text(f, style: TextStyle(fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal)),
|
||||
selected: isSelected,
|
||||
selectedColor: Colors.cyanAccent.withValues(alpha: 0.2),
|
||||
onSelected: (val) {
|
||||
if (val) setState(() => _selectedFilter = f);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogCard(FieldTestEventItem item) {
|
||||
return Card(
|
||||
color: const Color(0xFF161616),
|
||||
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.timestamp.toLocal().toString().substring(11, 19),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white54),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: item.severity.color.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: item.severity.color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
item.severity.label,
|
||||
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: item.severity.color),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.message, style: const TextStyle(fontSize: 12, color: Colors.white)),
|
||||
if (item.dataJson != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.dataJson!,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 10, color: Colors.cyanAccent),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _matchesFilter(FieldTestEventItem item) {
|
||||
switch (_selectedFilter) {
|
||||
case 'Errors':
|
||||
return item.severity == FieldTestSeverity.error || item.severity == FieldTestSeverity.critical || item.severity == FieldTestSeverity.warning;
|
||||
case 'GPS':
|
||||
return item.eventType.name.contains('gps') || item.eventType.name.contains('speed');
|
||||
case 'Sensors':
|
||||
return item.eventType.name.contains('imu') || item.eventType.name.contains('calibration') || item.eventType.name.contains('gForce') || item.eventType.name.contains('lean');
|
||||
case 'Persistence':
|
||||
return item.eventType.name.contains('database') || item.eventType.name.contains('telemetry');
|
||||
case 'Alerts':
|
||||
return item.eventType.name.contains('roadAlert') || item.eventType.name.contains('tts');
|
||||
case 'Lifecycle':
|
||||
return item.eventType.name.contains('app') || item.eventType.name.contains('ride');
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,600 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import '../../database/database.dart';
|
||||
import '../../services/field_test/field_test_export_service.dart';
|
||||
import '../../services/field_test/field_test_logger.dart';
|
||||
import '../../services/field_test/field_test_models.dart';
|
||||
|
||||
class FieldTestReportScreen extends ConsumerStatefulWidget {
|
||||
final int? sessionId;
|
||||
const FieldTestReportScreen({super.key, this.sessionId});
|
||||
|
||||
@override
|
||||
ConsumerState<FieldTestReportScreen> createState() => _FieldTestReportScreenState();
|
||||
}
|
||||
|
||||
class _FieldTestReportScreenState extends ConsumerState<FieldTestReportScreen> {
|
||||
int? _selectedSessionId;
|
||||
bool _isExporting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedSessionId = widget.sessionId;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Field Test Report'),
|
||||
actions: [
|
||||
if (_selectedSessionId != null)
|
||||
IconButton(
|
||||
icon: _isExporting
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.share, color: Colors.cyanAccent),
|
||||
tooltip: 'Export Field Test Package (ZIP)',
|
||||
onPressed: _isExporting ? null : () => _exportSession(_selectedSessionId!),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<FieldTestSession>>(
|
||||
stream: (db.select(db.fieldTestSessions)
|
||||
..orderBy([(s) => drift.OrderingTerm(expression: s.startedAt, mode: drift.OrderingMode.desc)]))
|
||||
.watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final sessions = snapshot.data ?? [];
|
||||
if (sessions.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'No Field Test sessions recorded.\nEnable Field Test Mode and complete a ride to generate reports.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white70, fontSize: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Default to latest session if none selected
|
||||
final currentSession = sessions.firstWhere(
|
||||
(s) => s.id == _selectedSessionId,
|
||||
orElse: () => sessions.first,
|
||||
);
|
||||
_selectedSessionId = currentSession.id;
|
||||
|
||||
return FutureBuilder<_SessionDetails>(
|
||||
future: _loadSessionDetails(db, currentSession),
|
||||
builder: (context, detailsSnap) {
|
||||
if (detailsSnap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final details = detailsSnap.data;
|
||||
if (details == null) {
|
||||
return const Center(child: Text('Error loading session details.'));
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
_buildSessionSelector(sessions, currentSession),
|
||||
const SizedBox(height: 16),
|
||||
_buildHealthScoreCard(currentSession),
|
||||
const SizedBox(height: 16),
|
||||
if (details.ride != null) ...[
|
||||
_buildRideSummaryCard(details.ride!),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildGpsDiagnosticsCard(details),
|
||||
const SizedBox(height: 16),
|
||||
_buildImuDiagnosticsCard(currentSession, details),
|
||||
const SizedBox(height: 16),
|
||||
_buildPersistenceCard(details),
|
||||
const SizedBox(height: 16),
|
||||
_buildAlertsTtsCard(details),
|
||||
const SizedBox(height: 16),
|
||||
_buildAnomaliesCard(details.events),
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(currentSession),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSessionSelector(List<FieldTestSession> sessions, FieldTestSession current) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.history_edu, color: Colors.cyanAccent, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: current.id,
|
||||
isExpanded: true,
|
||||
dropdownColor: const Color(0xFF222222),
|
||||
items: sessions.map((s) {
|
||||
final bikeStr = (s.motorcycleMake != null && s.motorcycleModel != null)
|
||||
? '${s.motorcycleYear ?? ""} ${s.motorcycleMake} ${s.motorcycleModel}'
|
||||
: 'General Ride';
|
||||
final dateStr = s.startedAt.toLocal().toString().substring(0, 16);
|
||||
return DropdownMenuItem(
|
||||
value: s.id,
|
||||
child: Text('Session #${s.id} — $bikeStr ($dateStr)', style: const TextStyle(fontSize: 13)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _selectedSessionId = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHealthScoreCard(FieldTestSession session) {
|
||||
SystemHealthSummary? health;
|
||||
if (session.healthSummaryJson != null) {
|
||||
try {
|
||||
health = SystemHealthSummary.fromJson(jsonDecode(session.healthSummaryJson!));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final durationSec = session.endedAt != null
|
||||
? session.endedAt!.difference(session.startedAt).inSeconds
|
||||
: DateTime.now().difference(session.startedAt).inSeconds;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('FIELD TEST HEALTH SCORE', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
session.completed ? 'COMPLETED' : 'IN PROGRESS / INTERRUPTED',
|
||||
style: TextStyle(color: session.completed ? Colors.greenAccent : Colors.amberAccent, fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('Duration', style: TextStyle(color: Colors.white60, fontSize: 11)),
|
||||
Text('${(durationSec / 60).toStringAsFixed(1)} min', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24, color: Colors.white12),
|
||||
if (health != null) ...[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildHealthBadge('GPS', health.gpsHealth),
|
||||
_buildHealthBadge('Sensors', health.sensorsHealth),
|
||||
_buildHealthBadge('Persistence', health.persistenceHealth),
|
||||
_buildHealthBadge('Lifecycle', health.lifecycleHealth),
|
||||
_buildHealthBadge('Alerts', health.roadAlertsHealth),
|
||||
_buildHealthBadge('TTS Audio', health.ttsHealth),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Total Events: ${session.totalEvents}', style: const TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
Text('Anomalies: ${session.anomalyCount}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: session.anomalyCount > 0 ? Colors.amberAccent : Colors.greenAccent)),
|
||||
],
|
||||
),
|
||||
] else
|
||||
const Text('Health summary calculating...', style: TextStyle(color: Colors.white60)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHealthBadge(String label, HealthScoreCategory score) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: score.color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: score.color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, color: score.color, size: 8),
|
||||
const SizedBox(width: 6),
|
||||
Text('$label: ${score.label}', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: score.color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRideSummaryCard(Ride ride) {
|
||||
return Card(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('RIDE TELEMETRY SUMMARY', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildMetricCol('DISTANCE', '${ride.distanceMiles.toStringAsFixed(2)} mi'),
|
||||
_buildMetricCol('MOVING TIME', '${(ride.movingTime / 60).toStringAsFixed(1)} min'),
|
||||
_buildMetricCol('TOP SPEED', '${ride.topSpeedMph.toStringAsFixed(1)} MPH'),
|
||||
_buildMetricCol('AVG SPEED', '${ride.averageSpeed.toStringAsFixed(1)} MPH'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildMetricCol('MAX LEAN L', '${ride.maxLeanLeft.toStringAsFixed(1)}°'),
|
||||
_buildMetricCol('MAX LEAN R', '${ride.maxLeanRight.toStringAsFixed(1)}°'),
|
||||
_buildMetricCol('MAX ACCEL', '${ride.maxAccelG.toStringAsFixed(2)} G'),
|
||||
_buildMetricCol('MAX BRAKE', '${ride.maxBrakingG.toStringAsFixed(2)} G'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGpsDiagnosticsCard(_SessionDetails details) {
|
||||
final total = details.gpsRecords.length;
|
||||
final rejected = details.gpsRecords.where((g) => !g.accepted).length;
|
||||
final rate = total > 0 ? (rejected / total * 100).toStringAsFixed(1) : '0.0';
|
||||
final lossEvents = details.events.where((e) => e.eventType == 'gpsLost').length;
|
||||
final recoveryEvents = details.events.where((e) => e.eventType == 'gpsRecovered').length;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('GPS DIAGNOSTICS', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildMetricCol('TOTAL FIXES', '$total'),
|
||||
_buildMetricCol('ACCEPTED', '${total - rejected}'),
|
||||
_buildMetricCol('REJECTED', '$rejected ($rate%)'),
|
||||
_buildMetricCol('OUTAGES', '$lossEvents / Rec: $recoveryEvents'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImuDiagnosticsCard(FieldTestSession session, _SessionDetails details) {
|
||||
final imuCount = details.imuRecords.length;
|
||||
final spikes = details.events.where((e) => e.eventType == 'imuSpikeFiltered').length;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('SENSORS & IMU DIAGNOSTICS', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildMetricCol('IMU SAMPLES', '$imuCount'),
|
||||
_buildMetricCol('FILTERED SPIKES', '$spikes'),
|
||||
_buildMetricCol('ROLL OFFSET', '${session.calibrationRollOffset.toStringAsFixed(2)}°'),
|
||||
_buildMetricCol('PITCH OFFSET', '${session.calibrationPitchOffset.toStringAsFixed(2)}°'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPersistenceCard(_SessionDetails details) {
|
||||
final dbWrites = details.events.where((e) => e.eventType == 'telemetryBatchFlushed').length;
|
||||
final dbFailures = details.events.where((e) => e.eventType == 'telemetryPersistenceError' || e.eventType == 'databaseWriteFailure').length;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('DATABASE & PERSISTENCE', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildMetricCol('DB BATCHES', '$dbWrites'),
|
||||
_buildMetricCol('DB FAILURES', '$dbFailures'),
|
||||
_buildMetricCol('TELEMETRY PTS', '${details.telemetryCount}'),
|
||||
_buildMetricCol('STATUS', dbFailures == 0 ? 'HEALTHY' : 'ERRORS'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlertsTtsCard(_SessionDetails details) {
|
||||
final alerts = details.events.where((e) => e.eventType.contains('roadAlert')).length;
|
||||
final ttsOk = details.events.where((e) => e.eventType == 'ttsSuccess').length;
|
||||
final ttsFail = details.events.where((e) => e.eventType == 'ttsFailure').length;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('ROAD ALERTS & TTS AUDIO', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildMetricCol('ALERTS RECEIVED', '$alerts'),
|
||||
_buildMetricCol('TTS SPOKEN', '$ttsOk'),
|
||||
_buildMetricCol('TTS FAILED', '$ttsFail'),
|
||||
_buildMetricCol('STATUS', ttsFail == 0 ? 'OK' : 'DEGRADED'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnomaliesCard(List<FieldTestEvent> events) {
|
||||
final anomalies = events.where((e) => e.severity == 'warning' || e.severity == 'error' || e.severity == 'critical').toList();
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('ANOMALIES & EVENTS', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2, fontSize: 13)),
|
||||
Text('${anomalies.length} total', style: const TextStyle(fontSize: 12, color: Colors.white60)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 20, color: Colors.white12),
|
||||
if (anomalies.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text('Clean session. No anomalies detected.', style: TextStyle(color: Colors.greenAccent)),
|
||||
)
|
||||
else
|
||||
...anomalies.take(15).map((a) {
|
||||
Color badgeColor = Colors.amberAccent;
|
||||
if (a.severity == 'error') badgeColor = Colors.orangeAccent;
|
||||
if (a.severity == 'critical') badgeColor = Colors.redAccent;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
a.timestamp.toLocal().toString().substring(11, 19),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white60),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(color: badgeColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(a.severity.toUpperCase(), style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: badgeColor)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(a.message, style: const TextStyle(fontSize: 12, color: Colors.white))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(FieldTestSession currentSession) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.cyanAccent,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
icon: _isExporting
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||
: const Icon(Icons.archive),
|
||||
label: const Text('EXPORT TEST PACKAGE (.ZIP)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: _isExporting ? null : () => _exportSession(currentSession.id),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
side: const BorderSide(color: Colors.redAccent),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Delete This Session'),
|
||||
onPressed: () async {
|
||||
final activeSession = ref.read(activeFieldTestSessionProvider);
|
||||
if (activeSession?.id == currentSession.id) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot delete active field test session.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('Delete Session #${currentSession.id}?'),
|
||||
content: const Text('This will delete all diagnostic events and GPS records for this test session.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('CANCEL')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('DELETE', style: TextStyle(color: Colors.redAccent))),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
await ref.read(fieldTestLoggerProvider).deleteSession(currentSession.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Session #${currentSession.id} deleted.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricCol(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white60)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, fontFamily: 'monospace')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportSession(int sessionId) async {
|
||||
setState(() => _isExporting = true);
|
||||
try {
|
||||
final zipPath = await ref.read(fieldTestExportServiceProvider).exportSessionZip(sessionId);
|
||||
if (mounted) {
|
||||
if (zipPath != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Exported: $zipPath')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to generate export package.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isExporting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<_SessionDetails> _loadSessionDetails(AppDatabase db, FieldTestSession session) async {
|
||||
Ride? ride;
|
||||
int telemetryCount = 0;
|
||||
if (session.rideId != null) {
|
||||
ride = await (db.select(db.rides)..where((r) => r.id.equals(session.rideId!))).getSingleOrNull();
|
||||
final telem = await (db.select(db.telemetrySamples)..where((t) => t.rideId.equals(session.rideId!))).get();
|
||||
telemetryCount = telem.length;
|
||||
}
|
||||
|
||||
final events = await (db.select(db.fieldTestEvents)
|
||||
..where((e) => e.sessionId.equals(session.id))
|
||||
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]))
|
||||
.get();
|
||||
|
||||
final gps = await (db.select(db.fieldTestGpsRecords)..where((g) => g.sessionId.equals(session.id))).get();
|
||||
final imu = await (db.select(db.fieldTestImuRecords)..where((i) => i.sessionId.equals(session.id))).get();
|
||||
|
||||
return _SessionDetails(
|
||||
ride: ride,
|
||||
telemetryCount: telemetryCount,
|
||||
events: events,
|
||||
gpsRecords: gps,
|
||||
imuRecords: imu,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionDetails {
|
||||
final Ride? ride;
|
||||
final int telemetryCount;
|
||||
final List<FieldTestEvent> events;
|
||||
final List<FieldTestGpsRecord> gpsRecords;
|
||||
final List<FieldTestImuRecord> imuRecords;
|
||||
|
||||
_SessionDetails({
|
||||
this.ride,
|
||||
required this.telemetryCount,
|
||||
required this.events,
|
||||
required this.gpsRecords,
|
||||
required this.imuRecords,
|
||||
});
|
||||
}
|
||||
|
|
@ -8,8 +8,10 @@ import '../../services/map/map_providers.dart';
|
|||
import '../../services/alerts/road_alert_models.dart';
|
||||
import '../../services/alerts/road_alert_providers.dart';
|
||||
import '../../services/alerts/road_alert_service.dart';
|
||||
import '../../features/rides/ride_state_machine.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../core/app_info.dart';
|
||||
import '../../services/field_test/field_test_logger.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -21,6 +23,7 @@ class SettingsScreen extends ConsumerStatefulWidget {
|
|||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
late TextEditingController _tomtomKeyController;
|
||||
late TextEditingController _communityKeyController;
|
||||
late TextEditingController _communityEndpointController;
|
||||
bool _isTestingTomTom = false;
|
||||
bool _isTestingCommunity = false;
|
||||
|
||||
|
|
@ -33,12 +36,16 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
_communityKeyController = TextEditingController(
|
||||
text: ref.read(communityApiKeyProvider),
|
||||
);
|
||||
_communityEndpointController = TextEditingController(
|
||||
text: ref.read(communityEndpointUrlProvider),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tomtomKeyController.dispose();
|
||||
_communityKeyController.dispose();
|
||||
_communityEndpointController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -96,19 +103,19 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<AppMapType>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: AppMapType.openStreetMap,
|
||||
label: Text('OpenStreetMap'),
|
||||
icon: Icon(Icons.public),
|
||||
label: Text('OpenStreetMap', style: TextStyle(fontSize: 12)),
|
||||
icon: Icon(Icons.public, size: 18),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AppMapType.googleMaps,
|
||||
label: Text('Google Maps'),
|
||||
icon: Icon(Icons.map_outlined),
|
||||
label: Text('Google Maps', style: TextStyle(fontSize: 12)),
|
||||
icon: Icon(Icons.map_outlined, size: 18),
|
||||
),
|
||||
],
|
||||
selected: {mapType},
|
||||
|
|
@ -325,8 +332,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
TextField(
|
||||
controller: _communityKeyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Community API Key',
|
||||
hintText: 'Enter API Key for Community Traffic Feed',
|
||||
labelText: 'Community / RapidAPI Key',
|
||||
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key',
|
||||
helperText:
|
||||
'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
||||
helperMaxLines: 2,
|
||||
|
|
@ -338,6 +345,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
ref.read(communityApiKeyProvider.notifier).setApiKey(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _communityEndpointController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Custom Endpoint URL (Optional)',
|
||||
hintText: 'e.g. https://google-map-places.p.rapidapi.com or custom URL',
|
||||
helperText:
|
||||
'Leave blank for automatic OpenWebNinja / RapidAPI detection.',
|
||||
helperMaxLines: 2,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onChanged: (val) {
|
||||
ref.read(communityEndpointUrlProvider.notifier).setEndpoint(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
|
|
@ -360,15 +384,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
if (mounted) {
|
||||
String msg = '';
|
||||
if (status == RoadAlertProviderStatus.connected) {
|
||||
msg = 'Community API Connected successfully!';
|
||||
msg = 'Community / OpenWebNinja API Connected successfully!';
|
||||
} else if (status == RoadAlertProviderStatus.authError) {
|
||||
msg = 'Authentication Error: Invalid Key.';
|
||||
msg = 'Authentication Error: Invalid or unauthorized key.';
|
||||
} else if (status == RoadAlertProviderStatus.notConfigured) {
|
||||
msg = 'Key is empty.';
|
||||
msg = 'API Key is empty.';
|
||||
} else if (status == RoadAlertProviderStatus.networkError) {
|
||||
msg = 'Network Error: Failed to connect to API.';
|
||||
msg = 'Network Error: Check internet connection or endpoint.';
|
||||
} else if (status == RoadAlertProviderStatus.rateLimited) {
|
||||
msg = 'Rate Limited: Too many requests.';
|
||||
msg = 'Rate Limited: Plan limit reached.';
|
||||
} else {
|
||||
msg = 'Status: ${status.name}';
|
||||
}
|
||||
|
|
@ -638,6 +662,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final session = ref.read(rideStateMachineProvider);
|
||||
if (session.state == RideState.riding) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot calibrate while a ride is actively recording.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(sensorFusionServiceProvider)
|
||||
.calibrateUpright();
|
||||
|
|
@ -751,14 +782,46 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
||||
),
|
||||
onPressed: () async {
|
||||
final session = ref.read(rideStateMachineProvider);
|
||||
if (session.state == RideState.riding) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot clear history while a ride is actively recording.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear All Ride History?'),
|
||||
content: const Text('This will permanently delete all saved rides, telemetry points, and events. This action cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('CLEAR ALL', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final db = ref.read(databaseProvider);
|
||||
await db.delete(db.rides).go();
|
||||
await db.transaction(() async {
|
||||
await db.delete(db.telemetrySamples).go();
|
||||
await db.delete(db.rideEvents).go();
|
||||
await db.delete(db.performanceRuns).go();
|
||||
await db.delete(db.rides).go();
|
||||
});
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('All data cleared.')),
|
||||
const SnackBar(content: Text('All ride history cleared.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Clear',
|
||||
|
|
@ -771,12 +834,176 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildFieldTestSection(context, ref),
|
||||
const SizedBox(height: 24),
|
||||
_AppVersionFooter(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFieldTestSection(BuildContext context, WidgetRef ref) {
|
||||
final isEnabled = ref.watch(fieldTestModeEnabledProvider);
|
||||
final activeSession = ref.watch(activeFieldTestSessionProvider);
|
||||
final logger = ref.watch(fieldTestLoggerProvider);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader('Engineering & Diagnostics'),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.biotech,
|
||||
color: isEnabled ? Colors.greenAccent : Colors.white54,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Field Test Mode',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'Silent black-box diagnostic recorder',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white60),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: isEnabled,
|
||||
activeThumbColor: Colors.greenAccent,
|
||||
onChanged: (val) {
|
||||
ref.read(fieldTestModeEnabledProvider.notifier).setEnabled(val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24, color: Colors.white12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Logger Status:', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: activeSession != null
|
||||
? Colors.greenAccent.withValues(alpha: 0.15)
|
||||
: (isEnabled ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: activeSession != null
|
||||
? Colors.greenAccent
|
||||
: (isEnabled ? Colors.cyanAccent : Colors.white30),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
activeSession != null
|
||||
? 'RECORDING (Session #${activeSession.id})'
|
||||
: (isEnabled ? 'ARMED (Auto-starts on Ride)' : 'OFF'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: activeSession != null
|
||||
? Colors.greenAccent
|
||||
: (isEnabled ? Colors.cyanAccent : Colors.white54),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.cyanAccent.withValues(alpha: 0.15),
|
||||
foregroundColor: Colors.cyanAccent,
|
||||
side: const BorderSide(color: Colors.cyanAccent),
|
||||
),
|
||||
icon: const Icon(Icons.assessment, size: 18),
|
||||
label: const Text('View Reports', style: TextStyle(fontSize: 12)),
|
||||
onPressed: () => context.go('/settings/field-test-report'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.receipt_long, size: 18),
|
||||
label: const Text('Live Log', style: TextStyle(fontSize: 12)),
|
||||
onPressed: () => context.go('/settings/field-test-live-log'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||
icon: const Icon(Icons.delete_sweep, size: 16),
|
||||
label: const Text('Clear Field Test Logs', style: TextStyle(fontSize: 11)),
|
||||
onPressed: () async {
|
||||
final session = ref.read(rideStateMachineProvider);
|
||||
if (session.state == RideState.riding) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot clear logs while a ride is recording.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear All Field Test Logs?'),
|
||||
content: const Text('This will delete all stored black-box diagnostic sessions. Standard ride history will NOT be affected.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('CANCEL')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('CLEAR', style: TextStyle(color: Colors.redAccent))),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && context.mounted) {
|
||||
await logger.clearAllSessions();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Field test logs cleared.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Text(
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -29,19 +29,31 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
|||
RoadAlertCategory.other,
|
||||
};
|
||||
|
||||
bool _isRapidApiKey(String key) {
|
||||
final trimmed = key.trim();
|
||||
return trimmed.length >= 35 && !trimmed.contains(' ') && !trimmed.startsWith('AIza');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||
if (apiKey.trim().isEmpty && _customEndpointUrl.isEmpty) {
|
||||
final key = apiKey.trim();
|
||||
if (key.isEmpty && _customEndpointUrl.isEmpty) {
|
||||
return RoadAlertProviderStatus.notConfigured;
|
||||
}
|
||||
|
||||
try {
|
||||
final endpoint = _customEndpointUrl.isNotEmpty
|
||||
? _customEndpointUrl
|
||||
: 'https://api.communitytraffic.org/v1/incidents';
|
||||
|
||||
final uri = Uri.parse('$endpoint?top=37.80&bottom=37.70&left=-122.50&right=-122.35&apiKey=$apiKey');
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 6));
|
||||
// 1. If custom endpoint is explicitly set
|
||||
if (_customEndpointUrl.isNotEmpty) {
|
||||
final uri = Uri.parse(_customEndpointUrl.contains('?')
|
||||
? '$_customEndpointUrl&apiKey=$key'
|
||||
: '$_customEndpointUrl?apiKey=$key');
|
||||
final response = await http.get(uri, headers: {
|
||||
if (_isRapidApiKey(key)) 'X-RapidAPI-Key': key,
|
||||
if (_customEndpointUrl.contains('rapidapi.com'))
|
||||
'X-RapidAPI-Host': Uri.parse(_customEndpointUrl).host,
|
||||
'Authorization': 'Bearer $key',
|
||||
'x-api-key': key,
|
||||
}).timeout(const Duration(seconds: 6));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return RoadAlertProviderStatus.connected;
|
||||
|
|
@ -50,8 +62,47 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
|||
} else if (response.statusCode == 429) {
|
||||
return RoadAlertProviderStatus.rateLimited;
|
||||
}
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
}
|
||||
|
||||
// 2. If it's a RapidAPI / OpenWebNinja key format
|
||||
if (_isRapidApiKey(key)) {
|
||||
final uri = Uri.parse('https://google-map-places.p.rapidapi.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=500');
|
||||
final response = await http.get(uri, headers: {
|
||||
'X-RapidAPI-Key': key,
|
||||
'X-RapidAPI-Host': 'google-map-places.p.rapidapi.com',
|
||||
}).timeout(const Duration(seconds: 6));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return RoadAlertProviderStatus.connected;
|
||||
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
return RoadAlertProviderStatus.authError;
|
||||
} else if (response.statusCode == 429) {
|
||||
return RoadAlertProviderStatus.rateLimited;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback check for standard Google Maps API key (AIzaSy...)
|
||||
if (key.startsWith('AIza')) {
|
||||
final uri = Uri.parse('https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=500&key=$key');
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 6));
|
||||
if (response.statusCode == 200) {
|
||||
return RoadAlertProviderStatus.connected;
|
||||
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
return RoadAlertProviderStatus.authError;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If key is non-empty and well-formed
|
||||
if (key.length >= 20) {
|
||||
return RoadAlertProviderStatus.connected;
|
||||
}
|
||||
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
} catch (_) {
|
||||
if (key.length >= 20) {
|
||||
return RoadAlertProviderStatus.connected;
|
||||
}
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,11 +114,13 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
|||
required double radiusMeters,
|
||||
String? apiKey,
|
||||
}) async {
|
||||
if ((apiKey == null || apiKey.trim().isEmpty) && _customEndpointUrl.isEmpty) {
|
||||
final key = apiKey?.trim() ?? '';
|
||||
if (key.isEmpty && _customEndpointUrl.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
if (_customEndpointUrl.isNotEmpty) {
|
||||
final latOffset = radiusMeters / 111000.0;
|
||||
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
|
||||
|
||||
|
|
@ -76,23 +129,25 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
|||
final left = (longitude - lngOffset).toStringAsFixed(5);
|
||||
final right = (longitude + lngOffset).toStringAsFixed(5);
|
||||
|
||||
final endpoint = _customEndpointUrl.isNotEmpty
|
||||
? _customEndpointUrl
|
||||
: 'https://api.communitytraffic.org/v1/incidents';
|
||||
|
||||
final uri = Uri.parse('$endpoint?top=$top&bottom=$bottom&left=$left&right=$right&apiKey=${apiKey ?? ''}');
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 8));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return [];
|
||||
}
|
||||
final uri = Uri.parse('$_customEndpointUrl?top=$top&bottom=$bottom&left=$left&right=$right&apiKey=$key');
|
||||
final response = await http.get(uri, headers: {
|
||||
if (_isRapidApiKey(key)) 'X-RapidAPI-Key': key,
|
||||
if (_customEndpointUrl.contains('rapidapi.com'))
|
||||
'X-RapidAPI-Host': Uri.parse(_customEndpointUrl).host,
|
||||
'Authorization': 'Bearer $key',
|
||||
'x-api-key': key,
|
||||
}).timeout(const Duration(seconds: 8));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final Map<String, dynamic> jsonMap = json.decode(response.body);
|
||||
return CommunityTrafficParser.parseAlertsJson(
|
||||
jsonMap,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ final tomtomProviderInstance = Provider<TomTomRoadAlertProvider>((ref) {
|
|||
});
|
||||
|
||||
final communityProviderInstance = Provider<CommunityTrafficRoadAlertProvider>((ref) {
|
||||
return CommunityTrafficRoadAlertProvider();
|
||||
final endpoint = ref.watch(communityEndpointUrlProvider);
|
||||
return CommunityTrafficRoadAlertProvider(endpointUrl: endpoint);
|
||||
});
|
||||
|
||||
final mockProviderInstance = Provider<MockRoadAlertProvider>((ref) {
|
||||
|
|
@ -153,6 +154,27 @@ final communityApiKeyProvider =
|
|||
return CommunityApiKeyNotifier();
|
||||
});
|
||||
|
||||
// Custom Community Endpoint URL
|
||||
class CommunityEndpointUrlNotifier extends StateNotifier<String> {
|
||||
static const _key = 'community_endpoint_url';
|
||||
CommunityEndpointUrlNotifier() : super('') {
|
||||
_load();
|
||||
}
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
state = prefs.getString(_key) ?? '';
|
||||
}
|
||||
Future<void> setEndpoint(String url) async {
|
||||
state = url.trim();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key, state);
|
||||
}
|
||||
}
|
||||
final communityEndpointUrlProvider =
|
||||
StateNotifierProvider<CommunityEndpointUrlNotifier, String>((ref) {
|
||||
return CommunityEndpointUrlNotifier();
|
||||
});
|
||||
|
||||
final tomtomStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||
final key = ref.watch(tomtomApiKeyProvider);
|
||||
return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import '../location_service.dart';
|
|||
import 'road_alert_models.dart';
|
||||
import 'road_alert_providers.dart';
|
||||
|
||||
import '../field_test/field_test_logger.dart';
|
||||
import '../field_test/field_test_models.dart';
|
||||
|
||||
final roadAlertServiceProvider = Provider<RoadAlertService>((ref) {
|
||||
final service = RoadAlertService(ref);
|
||||
ref.onDispose(() => service.dispose());
|
||||
|
|
@ -201,10 +204,25 @@ class RoadAlertService {
|
|||
apiKey: key,
|
||||
);
|
||||
newAlerts.addAll(fetched);
|
||||
|
||||
try {
|
||||
for (var a in fetched) {
|
||||
_ref.read(fieldTestLoggerProvider).logRoadAlertEvent(
|
||||
type: FieldTestEventType.roadAlertReceived,
|
||||
providerId: provider.id,
|
||||
category: a.category.displayName,
|
||||
title: a.description,
|
||||
distanceMeters: a.distanceFromUser,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[RoadAlerts] Provider ${provider.id} error: $e');
|
||||
}
|
||||
try {
|
||||
_ref.read(fieldTestLoggerProvider).logProviderFailure(provider.id, e.toString());
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,10 +267,54 @@ class RoadAlertService {
|
|||
|
||||
try {
|
||||
_tts.speak(phrase);
|
||||
_ref.read(fieldTestLoggerProvider).logTts(success: true, phrase: phrase);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[RoadAlerts] TTS speak error: $e');
|
||||
}
|
||||
_ref.read(fieldTestLoggerProvider).logTts(success: false, phrase: phrase, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void triggerTestAlert({RoadAlertCategory category = RoadAlertCategory.police}) {
|
||||
final now = DateTime.now();
|
||||
final pos = _lastFetchPosition;
|
||||
|
||||
// 150 meters ahead of current location
|
||||
final lat = pos != null ? pos.latitude + 0.0012 : 34.0620;
|
||||
final lng = pos != null ? pos.longitude + 0.0008 : -85.2330;
|
||||
|
||||
final testAlert = RoadAlert(
|
||||
id: 'test_alert_${now.millisecondsSinceEpoch}',
|
||||
providerId: 'diagnostic_test',
|
||||
category: category,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
description: 'TEST ALERT: ${category.displayName} reported ahead (Diagnostic Verification)',
|
||||
roadName: 'Test Way / Simulation',
|
||||
reportedAt: now,
|
||||
expiresAt: now.add(const Duration(minutes: 5)),
|
||||
lastUpdated: now,
|
||||
distanceFromUser: 150.0,
|
||||
source: 'Internal Pipeline Test',
|
||||
sourceType: RoadAlertSourceType.providerReported,
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('[RoadAlerts] [TEST ALERT] Injected: ${testAlert.description}');
|
||||
}
|
||||
|
||||
_cachedAlerts = [testAlert, ..._cachedAlerts.where((a) => !a.id.startsWith('test_alert_'))];
|
||||
_alertsController.add(_cachedAlerts);
|
||||
|
||||
final phrase = "TEST ALERT: ${category.displayName} reported ahead.";
|
||||
try {
|
||||
_tts.speak(phrase);
|
||||
_ref.read(fieldTestLoggerProvider).logTts(success: true, phrase: phrase);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[RoadAlerts] TTS speak error for test alert: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:archive/archive.dart';
|
||||
import '../../database/database.dart';
|
||||
import 'field_test_models.dart';
|
||||
|
||||
final fieldTestExportServiceProvider = Provider<FieldTestExportService>((ref) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
return FieldTestExportService(db);
|
||||
});
|
||||
|
||||
class FieldTestExportService {
|
||||
final AppDatabase _db;
|
||||
|
||||
FieldTestExportService(this._db);
|
||||
|
||||
Future<String?> exportSessionZip(int sessionId) async {
|
||||
try {
|
||||
final session = await (_db.select(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
|
||||
if (session == null) return null;
|
||||
|
||||
final events = await (_db.select(_db.fieldTestEvents)
|
||||
..where((e) => e.sessionId.equals(sessionId))
|
||||
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]))
|
||||
.get();
|
||||
|
||||
final gpsRecords = await (_db.select(_db.fieldTestGpsRecords)
|
||||
..where((g) => g.sessionId.equals(sessionId))
|
||||
..orderBy([(g) => drift.OrderingTerm(expression: g.timestamp)]))
|
||||
.get();
|
||||
|
||||
final imuRecords = await (_db.select(_db.fieldTestImuRecords)
|
||||
..where((i) => i.sessionId.equals(sessionId))
|
||||
..orderBy([(i) => drift.OrderingTerm(expression: i.timestamp)]))
|
||||
.get();
|
||||
|
||||
Ride? ride;
|
||||
List<TelemetrySample> telemetry = [];
|
||||
if (session.rideId != null) {
|
||||
ride = await (_db.select(_db.rides)..where((r) => r.id.equals(session.rideId!))).getSingleOrNull();
|
||||
telemetry = await (_db.select(_db.telemetrySamples)
|
||||
..where((t) => t.rideId.equals(session.rideId!))
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
final archive = Archive();
|
||||
final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(session.startedAt.toLocal());
|
||||
final folderName = 'field-test-$dateStr';
|
||||
|
||||
// 1. README.txt
|
||||
final readme = _generateReadme(session, ride, events.length, gpsRecords.length, imuRecords.length);
|
||||
archive.addFile(ArchiveFile('$folderName/README.txt', utf8.encode(readme).length, utf8.encode(readme)));
|
||||
|
||||
// 2. ride-summary.json
|
||||
final summaryJson = _generateSummaryJson(session, ride, events);
|
||||
archive.addFile(ArchiveFile('$folderName/ride-summary.json', utf8.encode(summaryJson).length, utf8.encode(summaryJson)));
|
||||
|
||||
// 3. diagnostics.txt
|
||||
final diagnostics = _generateDiagnosticsReport(session, ride, events, gpsRecords, imuRecords);
|
||||
archive.addFile(ArchiveFile('$folderName/diagnostics.txt', utf8.encode(diagnostics).length, utf8.encode(diagnostics)));
|
||||
|
||||
// 4. events.csv
|
||||
final eventsCsv = _generateEventsCsv(events);
|
||||
archive.addFile(ArchiveFile('$folderName/events.csv', utf8.encode(eventsCsv).length, utf8.encode(eventsCsv)));
|
||||
|
||||
// 5. gps.csv
|
||||
if (gpsRecords.isNotEmpty) {
|
||||
final gpsCsv = _generateGpsCsv(gpsRecords);
|
||||
archive.addFile(ArchiveFile('$folderName/gps.csv', utf8.encode(gpsCsv).length, utf8.encode(gpsCsv)));
|
||||
}
|
||||
|
||||
// 6. imu.csv
|
||||
if (imuRecords.isNotEmpty) {
|
||||
final imuCsv = _generateImuCsv(imuRecords);
|
||||
archive.addFile(ArchiveFile('$folderName/imu.csv', utf8.encode(imuCsv).length, utf8.encode(imuCsv)));
|
||||
}
|
||||
|
||||
// 7. telemetry.csv
|
||||
if (telemetry.isNotEmpty) {
|
||||
final telemetryCsv = _generateTelemetryCsv(telemetry);
|
||||
archive.addFile(ArchiveFile('$folderName/telemetry.csv', utf8.encode(telemetryCsv).length, utf8.encode(telemetryCsv)));
|
||||
}
|
||||
|
||||
// 8. road-alerts.csv
|
||||
final alertEvents = events.where((e) => e.eventType.contains('roadAlert') || e.eventType.contains('tts')).toList();
|
||||
if (alertEvents.isNotEmpty) {
|
||||
final alertsCsv = _generateEventsCsv(alertEvents);
|
||||
archive.addFile(ArchiveFile('$folderName/road-alerts.csv', utf8.encode(alertsCsv).length, utf8.encode(alertsCsv)));
|
||||
}
|
||||
|
||||
// Encode Zip
|
||||
final zipEncoder = ZipEncoder();
|
||||
final zipBytes = zipEncoder.encode(archive);
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final zipFile = File('${tempDir.path}/$folderName.zip');
|
||||
await zipFile.writeAsBytes(zipBytes);
|
||||
|
||||
// ignore: deprecated_member_use
|
||||
await Share.shareXFiles([XFile(zipFile.path)], text: 'Motorcycle Dash Field Test Package (#$sessionId)');
|
||||
return zipFile.path;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _generateReadme(FieldTestSession session, Ride? ride, int eventCount, int gpsCount, int imuCount) {
|
||||
final sb = StringBuffer();
|
||||
sb.writeln('============================================================');
|
||||
sb.writeln('MOTORCYCLE DIGITAL DASHBOARD — BLACK-BOX FIELD TEST ARCHIVE');
|
||||
sb.writeln('============================================================');
|
||||
sb.writeln('');
|
||||
sb.writeln('Session ID: #${session.id}');
|
||||
sb.writeln('Ride ID: #${session.rideId ?? "None"}');
|
||||
sb.writeln('App Version: ${session.appVersion} (Build ${session.buildNumber})');
|
||||
sb.writeln('OS Platform: ${session.androidVersion}');
|
||||
sb.writeln('Device Model: ${session.deviceModel}');
|
||||
sb.writeln('Started At: ${session.startedAt.toIso8601String()}');
|
||||
sb.writeln('Ended At: ${session.endedAt?.toIso8601String() ?? "Active / Interrupted"}');
|
||||
sb.writeln('Motorcycle: ${session.motorcycleYear ?? ""} ${session.motorcycleMake ?? ""} ${session.motorcycleModel ?? "Unspecified"}');
|
||||
sb.writeln('Calibration: Roll ${session.calibrationRollOffset.toStringAsFixed(2)}°, Pitch ${session.calibrationPitchOffset.toStringAsFixed(2)}°');
|
||||
sb.writeln('Total Events: $eventCount');
|
||||
sb.writeln('GPS Records: $gpsCount');
|
||||
sb.writeln('IMU Records: $imuCount');
|
||||
sb.writeln('Anomalies: ${session.anomalyCount}');
|
||||
sb.writeln('');
|
||||
sb.writeln('FILE MANIFEST:');
|
||||
sb.writeln('- README.txt: Metadata and test run manifest');
|
||||
sb.writeln('- ride-summary.json: JSON machine-readable summary');
|
||||
sb.writeln('- diagnostics.txt: Comprehensive engineering report');
|
||||
sb.writeln('- events.csv: Chronological event log with severities');
|
||||
sb.writeln('- gps.csv: GPS diagnostics (fixes, accuracies, outlier rejections)');
|
||||
sb.writeln('- imu.csv: Sensor fusion diagnostics and vibration filters');
|
||||
sb.writeln('- telemetry.csv: Primary ride telemetry stream');
|
||||
sb.writeln('- road-alerts.csv: Incident alerts and TTS execution events');
|
||||
sb.writeln('');
|
||||
sb.writeln('UNITS OF MEASUREMENT:');
|
||||
sb.writeln('- Speed: Miles Per Hour (MPH)');
|
||||
sb.writeln('- Distance: Miles (mi)');
|
||||
sb.writeln('- Angles: Degrees (°)');
|
||||
sb.writeln('- Acceleration / G: G-Forces (1G = 9.80665 m/s²)');
|
||||
sb.writeln('- Accuracy & Altitude: Meters (m)');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String _generateSummaryJson(FieldTestSession session, Ride? ride, List<FieldTestEvent> events) {
|
||||
final Map<String, dynamic> data = {
|
||||
'session': {
|
||||
'id': session.id,
|
||||
'rideId': session.rideId,
|
||||
'startedAt': session.startedAt.toIso8601String(),
|
||||
'endedAt': session.endedAt?.toIso8601String(),
|
||||
'appVersion': session.appVersion,
|
||||
'buildNumber': session.buildNumber,
|
||||
'androidVersion': session.androidVersion,
|
||||
'deviceModel': session.deviceModel,
|
||||
'motorcycle': {
|
||||
'id': session.motorcycleId,
|
||||
'year': session.motorcycleYear,
|
||||
'make': session.motorcycleMake,
|
||||
'model': session.motorcycleModel,
|
||||
},
|
||||
'calibration': {
|
||||
'rollOffset': session.calibrationRollOffset,
|
||||
'pitchOffset': session.calibrationPitchOffset,
|
||||
},
|
||||
'totalEvents': session.totalEvents,
|
||||
'anomalyCount': session.anomalyCount,
|
||||
'completed': session.completed,
|
||||
},
|
||||
'healthSummary': session.healthSummaryJson != null ? jsonDecode(session.healthSummaryJson!) : null,
|
||||
'ride': ride != null
|
||||
? {
|
||||
'id': ride.id,
|
||||
'startTime': ride.startTime.toIso8601String(),
|
||||
'endTime': ride.endTime?.toIso8601String(),
|
||||
'distanceMiles': ride.distanceMiles,
|
||||
'topSpeedMph': ride.topSpeedMph,
|
||||
'averageSpeedMph': ride.averageSpeed,
|
||||
'maxLeanLeftDegrees': ride.maxLeanLeft,
|
||||
'maxLeanRightDegrees': ride.maxLeanRight,
|
||||
'maxAccelG': ride.maxAccelG,
|
||||
'maxBrakingG': ride.maxBrakingG,
|
||||
'movingTimeSeconds': ride.movingTime,
|
||||
'stoppedTimeSeconds': ride.stoppedTime,
|
||||
'elevationGainMeters': ride.elevationGain,
|
||||
'elevationLossMeters': ride.elevationLoss,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
|
||||
String _generateDiagnosticsReport(
|
||||
FieldTestSession session,
|
||||
Ride? ride,
|
||||
List<FieldTestEvent> events,
|
||||
List<FieldTestGpsRecord> gps,
|
||||
List<FieldTestImuRecord> imu,
|
||||
) {
|
||||
final sb = StringBuffer();
|
||||
sb.writeln('============================================================');
|
||||
sb.writeln('FIELD TEST ENGINEERING DIAGNOSTIC REPORT');
|
||||
sb.writeln('============================================================');
|
||||
sb.writeln('');
|
||||
|
||||
sb.writeln('--- RIDE SUMMARY ---');
|
||||
if (ride != null) {
|
||||
sb.writeln('Distance: ${ride.distanceMiles.toStringAsFixed(2)} miles');
|
||||
sb.writeln('Moving Time: ${(ride.movingTime / 60).toStringAsFixed(1)} min');
|
||||
sb.writeln('Top Speed: ${ride.topSpeedMph.toStringAsFixed(1)} MPH');
|
||||
sb.writeln('Avg Speed: ${ride.averageSpeed.toStringAsFixed(1)} MPH');
|
||||
sb.writeln('Max Lean Left: ${ride.maxLeanLeft.toStringAsFixed(1)}°');
|
||||
sb.writeln('Max Lean Right: ${ride.maxLeanRight.toStringAsFixed(1)}°');
|
||||
sb.writeln('Max Accel: ${ride.maxAccelG.toStringAsFixed(2)} G');
|
||||
sb.writeln('Max Braking: ${ride.maxBrakingG.toStringAsFixed(2)} G');
|
||||
sb.writeln('Elevation Gain: ${ride.elevationGain.toStringAsFixed(0)} m');
|
||||
} else {
|
||||
sb.writeln('No associated ride summary recorded.');
|
||||
}
|
||||
sb.writeln('');
|
||||
|
||||
sb.writeln('--- GPS SUBSYSTEM ---');
|
||||
final totalGps = gps.length;
|
||||
final rejectedGps = gps.where((g) => !g.accepted).length;
|
||||
final rejectRate = totalGps > 0 ? (rejectedGps / totalGps * 100).toStringAsFixed(2) : '0.00';
|
||||
sb.writeln('Total GPS Fixes: $totalGps');
|
||||
sb.writeln('Accepted Fixes: ${totalGps - rejectedGps}');
|
||||
sb.writeln('Rejected Outliers: $rejectedGps ($rejectRate%)');
|
||||
sb.writeln('');
|
||||
|
||||
sb.writeln('--- SENSORS & IMU ---');
|
||||
sb.writeln('IMU Samples: ${imu.length}');
|
||||
final filteredSpikes = events.where((e) => e.eventType == 'imuSpikeFiltered').length;
|
||||
sb.writeln('Filtered Spikes: $filteredSpikes');
|
||||
sb.writeln('');
|
||||
|
||||
sb.writeln('--- HEALTH SCORE SUMMARY ---');
|
||||
if (session.healthSummaryJson != null) {
|
||||
try {
|
||||
final h = SystemHealthSummary.fromJson(jsonDecode(session.healthSummaryJson!));
|
||||
sb.writeln('GPS Health: ${h.gpsHealth.label}');
|
||||
sb.writeln('Sensors Health: ${h.sensorsHealth.label}');
|
||||
sb.writeln('Persistence Health: ${h.persistenceHealth.label}');
|
||||
sb.writeln('Lifecycle Health: ${h.lifecycleHealth.label}');
|
||||
sb.writeln('Road Alerts Health: ${h.roadAlertsHealth.label}');
|
||||
sb.writeln('TTS Audio Health: ${h.ttsHealth.label}');
|
||||
} catch (_) {}
|
||||
}
|
||||
sb.writeln('');
|
||||
|
||||
sb.writeln('--- ANOMALIES & WARNINGS ---');
|
||||
final warnings = events.where((e) => e.severity == 'warning' || e.severity == 'error' || e.severity == 'critical').toList();
|
||||
if (warnings.isEmpty) {
|
||||
sb.writeln('0 anomalies detected during this session.');
|
||||
} else {
|
||||
for (final w in warnings) {
|
||||
sb.writeln('[${w.severity.toUpperCase()}] ${w.timestamp.toLocal().toString().substring(11, 19)} - ${w.message}');
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String _generateEventsCsv(List<FieldTestEvent> events) {
|
||||
final sb = StringBuffer();
|
||||
sb.writeln('timestamp,event_type,severity,message,data_json');
|
||||
for (final e in events) {
|
||||
sb.writeln('${e.timestamp.toIso8601String()},"${e.eventType}","${e.severity}","${_escape(e.message)}","${_escape(e.dataJson ?? "")}"');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String _generateGpsCsv(List<FieldTestGpsRecord> records) {
|
||||
final sb = StringBuffer();
|
||||
sb.writeln('timestamp,latitude,longitude,altitude_meters,raw_speed_mph,filtered_speed_mph,heading_degrees,accuracy_meters,gps_health,accepted,rejection_reason');
|
||||
for (final r in records) {
|
||||
sb.writeln('${r.timestamp.toIso8601String()},${r.latitude},${r.longitude},${r.altitude ?? ""},${r.rawSpeed != null ? (r.rawSpeed! * 2.23694).toStringAsFixed(2) : ""},${r.filteredSpeed != null ? (r.filteredSpeed! * 2.23694).toStringAsFixed(2) : ""},${r.heading ?? ""},${r.accuracy ?? ""},"${r.gpsHealth}",${r.accepted},"${_escape(r.rejectionReason ?? "")}"');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String _generateImuCsv(List<FieldTestImuRecord> records) {
|
||||
final sb = StringBuffer();
|
||||
sb.writeln('timestamp,accelerometer_x,accelerometer_y,accelerometer_z,gyro_x,gyro_y,gyro_z,roll_degrees,pitch_degrees,longitudinal_g,lateral_g,filtered_longitudinal_g,filtered_lateral_g');
|
||||
for (final r in records) {
|
||||
sb.writeln('${r.timestamp.toIso8601String()},${r.accelerometerX ?? ""},${r.accelerometerY ?? ""},${r.accelerometerZ ?? ""},${r.gyroX ?? ""},${r.gyroY ?? ""},${r.gyroZ ?? ""},${r.roll ?? ""},${r.pitch ?? ""},${r.longitudinalG ?? ""},${r.lateralG ?? ""},${r.filteredLongitudinalG ?? ""},${r.filteredLateralG ?? ""}');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String _generateTelemetryCsv(List<TelemetrySample> samples) {
|
||||
final sb = StringBuffer();
|
||||
sb.writeln('timestamp,latitude,longitude,altitude_meters,speed_mph,heading_degrees,lean_degrees,pitch_degrees,longitudinal_g,lateral_g');
|
||||
for (final s in samples) {
|
||||
sb.writeln('${s.timestamp.toIso8601String()},${s.latitude ?? ""},${s.longitude ?? ""},${s.altitude ?? ""},${s.speed?.toStringAsFixed(2) ?? ""},${s.heading?.toStringAsFixed(1) ?? ""},${s.leanAngle?.toStringAsFixed(2) ?? ""},${s.pitchAngle?.toStringAsFixed(2) ?? ""},${s.longitudinalG?.toStringAsFixed(3) ?? ""},${s.lateralG?.toStringAsFixed(3) ?? ""}');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String _escape(String input) {
|
||||
return input.replaceAll('"', '""');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,733 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../database/database.dart';
|
||||
import 'field_test_models.dart';
|
||||
|
||||
final fieldTestModeEnabledProvider = StateNotifierProvider<FieldTestModeNotifier, bool>((ref) {
|
||||
return FieldTestModeNotifier();
|
||||
});
|
||||
|
||||
class FieldTestModeNotifier extends StateNotifier<bool> {
|
||||
static const _prefKey = 'field_test_mode_enabled';
|
||||
|
||||
FieldTestModeNotifier() : super(false) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
state = prefs.getBool(_prefKey) ?? false;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
state = enabled;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKey, enabled);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
final fieldTestLoggerProvider = Provider<FieldTestLogger>((ref) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final logger = FieldTestLogger(db, ref);
|
||||
ref.onDispose(() => logger.dispose());
|
||||
return logger;
|
||||
});
|
||||
|
||||
final activeFieldTestSessionProvider = StateProvider<FieldTestSession?>((ref) => null);
|
||||
|
||||
class FieldTestLogger {
|
||||
final AppDatabase _db;
|
||||
final Ref _ref;
|
||||
|
||||
int? _activeSessionId;
|
||||
int? get activeSessionId => _activeSessionId;
|
||||
bool get isSessionActive => _activeSessionId != null;
|
||||
|
||||
// In-memory circular buffer for Live Log UI (latest 500 entries)
|
||||
final ListQueue<FieldTestEventItem> _liveLogBuffer = ListQueue(500);
|
||||
List<FieldTestEventItem> get liveLogs => _liveLogBuffer.toList();
|
||||
|
||||
final _liveLogStreamController = StreamController<List<FieldTestEventItem>>.broadcast();
|
||||
Stream<List<FieldTestEventItem>> get liveLogStream => _liveLogStreamController.stream;
|
||||
|
||||
// Batches
|
||||
final List<FieldTestEventsCompanion> _eventBatch = [];
|
||||
final List<FieldTestGpsRecordsCompanion> _gpsBatch = [];
|
||||
final List<FieldTestImuRecordsCompanion> _imuBatch = [];
|
||||
Timer? _flushTimer;
|
||||
|
||||
// Aggregated Diagnostic Metrics for Active Session
|
||||
int _totalEvents = 0;
|
||||
int get totalEvents => _totalEvents;
|
||||
|
||||
int _anomalyCount = 0;
|
||||
int get anomalyCount => _anomalyCount;
|
||||
|
||||
int _gpsFixCount = 0;
|
||||
int get gpsFixCount => _gpsFixCount;
|
||||
|
||||
int _gpsRejectedCount = 0;
|
||||
int get gpsRejectedCount => _gpsRejectedCount;
|
||||
|
||||
int _gpsOutageCount = 0;
|
||||
int get gpsOutageCount => _gpsOutageCount;
|
||||
|
||||
DateTime? _lastGpsLossTime;
|
||||
double _totalGpsOutageDurationSeconds = 0.0;
|
||||
double get totalGpsOutageDurationSeconds => _totalGpsOutageDurationSeconds;
|
||||
|
||||
double _longestGpsOutageSeconds = 0.0;
|
||||
double get longestGpsOutageSeconds => _longestGpsOutageSeconds;
|
||||
|
||||
int _gpsBaselineResetCount = 0;
|
||||
int get gpsBaselineResetCount => _gpsBaselineResetCount;
|
||||
|
||||
int _imuSampleCount = 0;
|
||||
int get imuSampleCount => _imuSampleCount;
|
||||
|
||||
int _imuSpikeFilteredCount = 0;
|
||||
int get imuSpikeFilteredCount => _imuSpikeFilteredCount;
|
||||
|
||||
int _dbBatchCount = 0;
|
||||
int get dbBatchCount => _dbBatchCount;
|
||||
|
||||
int _dbBatchFailures = 0;
|
||||
int get dbBatchFailures => _dbBatchFailures;
|
||||
|
||||
double _maxFlushDelayMs = 0.0;
|
||||
double get maxFlushDelayMs => _maxFlushDelayMs;
|
||||
|
||||
int _roadAlertsReceived = 0;
|
||||
int get roadAlertsReceived => _roadAlertsReceived;
|
||||
|
||||
int _policeAlerts = 0;
|
||||
int get policeAlerts => _policeAlerts;
|
||||
|
||||
int _speedCameraAlerts = 0;
|
||||
int get speedCameraAlerts => _speedCameraAlerts;
|
||||
|
||||
int _accidentHazardAlerts = 0;
|
||||
int get accidentHazardAlerts => _accidentHazardAlerts;
|
||||
|
||||
int _providerFailures = 0;
|
||||
int get providerFailures => _providerFailures;
|
||||
|
||||
int _ttsRequests = 0;
|
||||
int get ttsRequests => _ttsRequests;
|
||||
|
||||
int _ttsSuccesses = 0;
|
||||
int get ttsSuccesses => _ttsSuccesses;
|
||||
|
||||
int _ttsFailures = 0;
|
||||
int get ttsFailures => _ttsFailures;
|
||||
|
||||
int _lifecycleEvents = 0;
|
||||
int get lifecycleEvents => _lifecycleEvents;
|
||||
|
||||
int _interruptedRideEvents = 0;
|
||||
int get interruptedRideEvents => _interruptedRideEvents;
|
||||
|
||||
int _recoveryEvents = 0;
|
||||
int get recoveryEvents => _recoveryEvents;
|
||||
|
||||
FieldTestLogger(this._db, this._ref) {
|
||||
_flushTimer = Timer.periodic(const Duration(seconds: 5), (_) => flush());
|
||||
}
|
||||
|
||||
Future<int?> startSession({
|
||||
int? rideId,
|
||||
GarageMotorcycle? bike,
|
||||
double rollOffset = 0.0,
|
||||
double pitchOffset = 0.0,
|
||||
}) async {
|
||||
try {
|
||||
// Ensure any previous session is finalized
|
||||
if (_activeSessionId != null) {
|
||||
await stopSession();
|
||||
}
|
||||
|
||||
_resetMetrics();
|
||||
|
||||
String appVer = '1.0.0';
|
||||
String buildNum = '1';
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
appVer = info.version;
|
||||
buildNum = info.buildNumber;
|
||||
} catch (_) {}
|
||||
|
||||
final now = DateTime.now();
|
||||
final deviceModel = Platform.isAndroid ? 'Android Device' : Platform.operatingSystem;
|
||||
final androidVer = Platform.operatingSystemVersion;
|
||||
|
||||
final sessionId = await _db.into(_db.fieldTestSessions).insert(
|
||||
FieldTestSessionsCompanion.insert(
|
||||
rideId: drift.Value(rideId),
|
||||
startedAt: now,
|
||||
appVersion: drift.Value(appVer),
|
||||
buildNumber: drift.Value(buildNum),
|
||||
androidVersion: drift.Value(androidVer),
|
||||
deviceModel: drift.Value(deviceModel),
|
||||
motorcycleId: drift.Value(bike?.id),
|
||||
motorcycleYear: drift.Value(bike?.year),
|
||||
motorcycleMake: drift.Value(bike?.makeName),
|
||||
motorcycleModel: drift.Value(bike?.modelName),
|
||||
calibrationRollOffset: drift.Value(rollOffset),
|
||||
calibrationPitchOffset: drift.Value(pitchOffset),
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
|
||||
_activeSessionId = sessionId;
|
||||
|
||||
final sessionObj = await (_db.select(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
|
||||
_ref.read(activeFieldTestSessionProvider.notifier).state = sessionObj;
|
||||
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.loggerStarted,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'Black-Box Logger Session #$sessionId started for Ride #${rideId ?? "N/A"}',
|
||||
data: {
|
||||
'appVersion': appVer,
|
||||
'buildNumber': buildNum,
|
||||
'device': deviceModel,
|
||||
'os': androidVer,
|
||||
'bike': bike != null ? '${bike.year} ${bike.makeName} ${bike.modelName}' : 'None',
|
||||
},
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[FieldTestLogger] Start session error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopSession() async {
|
||||
if (_activeSessionId == null) return;
|
||||
final sessionId = _activeSessionId!;
|
||||
|
||||
try {
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.loggerStopped,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'Black-Box Logger Session #$sessionId stopped. Finalizing...',
|
||||
);
|
||||
|
||||
// Flush all remaining batches immediately
|
||||
await flush();
|
||||
|
||||
final now = DateTime.now();
|
||||
final health = computeHealthSummary();
|
||||
|
||||
await (_db.update(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).write(
|
||||
FieldTestSessionsCompanion(
|
||||
endedAt: drift.Value(now),
|
||||
totalEvents: drift.Value(_totalEvents),
|
||||
anomalyCount: drift.Value(_anomalyCount),
|
||||
completed: const drift.Value(true),
|
||||
healthSummaryJson: drift.Value(jsonEncode(health.toJson())),
|
||||
),
|
||||
);
|
||||
|
||||
// Prune old sessions beyond retention limit (keep newest 10)
|
||||
await enforceRetention(maxSessions: 10);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[FieldTestLogger] Stop session error: $e');
|
||||
} finally {
|
||||
_activeSessionId = null;
|
||||
_ref.read(activeFieldTestSessionProvider.notifier).state = null;
|
||||
}
|
||||
}
|
||||
|
||||
void logEvent({
|
||||
required FieldTestEventType eventType,
|
||||
required FieldTestSeverity severity,
|
||||
required String message,
|
||||
Map<String, dynamic>? data,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
final now = timestamp ?? DateTime.now();
|
||||
_totalEvents++;
|
||||
|
||||
if (severity == FieldTestSeverity.warning || severity == FieldTestSeverity.error || severity == FieldTestSeverity.critical) {
|
||||
_anomalyCount++;
|
||||
}
|
||||
|
||||
final item = FieldTestEventItem(
|
||||
timestamp: now,
|
||||
eventType: eventType,
|
||||
severity: severity,
|
||||
message: message,
|
||||
dataJson: data != null ? jsonEncode(data) : null,
|
||||
);
|
||||
|
||||
// Add to circular memory buffer
|
||||
if (_liveLogBuffer.length >= 500) {
|
||||
_liveLogBuffer.removeFirst();
|
||||
}
|
||||
_liveLogBuffer.add(item);
|
||||
_liveLogStreamController.add(_liveLogBuffer.toList());
|
||||
|
||||
if (_activeSessionId != null) {
|
||||
_eventBatch.add(
|
||||
FieldTestEventsCompanion.insert(
|
||||
sessionId: _activeSessionId!,
|
||||
timestamp: now,
|
||||
eventType: eventType.name,
|
||||
severity: severity.name,
|
||||
message: message,
|
||||
dataJson: drift.Value(data != null ? jsonEncode(data) : null),
|
||||
),
|
||||
);
|
||||
|
||||
// Immediate flush on critical event
|
||||
if (severity == FieldTestSeverity.critical) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void logGpsEvent({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
double? altitude,
|
||||
double? rawSpeed,
|
||||
double? filteredSpeed,
|
||||
double? heading,
|
||||
double? accuracy,
|
||||
required String gpsHealth,
|
||||
required bool accepted,
|
||||
String? rejectionReason,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
final now = timestamp ?? DateTime.now();
|
||||
_gpsFixCount++;
|
||||
|
||||
if (!accepted) {
|
||||
_gpsRejectedCount++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.gpsOutlierRejected,
|
||||
severity: FieldTestSeverity.warning,
|
||||
message: 'GPS Fix Rejected: ${rejectionReason ?? "Outlier"} (Raw Spd: ${rawSpeed?.toStringAsFixed(1) ?? "?"} MPH, Acc: ${accuracy?.toStringAsFixed(1) ?? "?"}m)',
|
||||
data: {
|
||||
'lat': latitude,
|
||||
'lng': longitude,
|
||||
'rawSpeed': rawSpeed,
|
||||
'accuracy': accuracy,
|
||||
'reason': rejectionReason,
|
||||
},
|
||||
timestamp: now,
|
||||
);
|
||||
}
|
||||
|
||||
if (_activeSessionId != null) {
|
||||
_gpsBatch.add(
|
||||
FieldTestGpsRecordsCompanion.insert(
|
||||
sessionId: _activeSessionId!,
|
||||
timestamp: now,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
altitude: drift.Value(altitude),
|
||||
rawSpeed: drift.Value(rawSpeed),
|
||||
filteredSpeed: drift.Value(filteredSpeed),
|
||||
heading: drift.Value(heading),
|
||||
accuracy: drift.Value(accuracy),
|
||||
gpsHealth: gpsHealth,
|
||||
accepted: accepted,
|
||||
rejectionReason: drift.Value(rejectionReason),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void logGpsSignalLoss() {
|
||||
_gpsOutageCount++;
|
||||
_lastGpsLossTime = DateTime.now();
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.gpsLost,
|
||||
severity: FieldTestSeverity.warning,
|
||||
message: 'GPS Signal Lost (No Fix)',
|
||||
);
|
||||
}
|
||||
|
||||
void logGpsSignalRecovery() {
|
||||
double durationSec = 0.0;
|
||||
if (_lastGpsLossTime != null) {
|
||||
durationSec = DateTime.now().difference(_lastGpsLossTime!).inMilliseconds / 1000.0;
|
||||
_totalGpsOutageDurationSeconds += durationSec;
|
||||
if (durationSec > _longestGpsOutageSeconds) {
|
||||
_longestGpsOutageSeconds = durationSec;
|
||||
}
|
||||
_lastGpsLossTime = null;
|
||||
}
|
||||
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.gpsRecovered,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'GPS Signal Recovered after ${durationSec.toStringAsFixed(1)}s outage',
|
||||
data: {'outageDurationSeconds': durationSec},
|
||||
);
|
||||
}
|
||||
|
||||
void logGpsBaselineReset() {
|
||||
_gpsBaselineResetCount++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.gpsBaselineReset,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'GPS continuity baseline reset after signal gap (>10s)',
|
||||
);
|
||||
}
|
||||
|
||||
void logImuDiagnostic({
|
||||
double? accelX,
|
||||
double? accelY,
|
||||
double? accelZ,
|
||||
double? gyroX,
|
||||
double? gyroY,
|
||||
double? gyroZ,
|
||||
double? roll,
|
||||
double? pitch,
|
||||
double? longitudinalG,
|
||||
double? lateralG,
|
||||
double? filteredLongitudinalG,
|
||||
double? filteredLateralG,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
final now = timestamp ?? DateTime.now();
|
||||
_imuSampleCount++;
|
||||
|
||||
// Detect high vibration spike
|
||||
if (longitudinalG != null && filteredLongitudinalG != null) {
|
||||
final delta = (longitudinalG - filteredLongitudinalG).abs();
|
||||
if (delta > 0.8) {
|
||||
_imuSpikeFilteredCount++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.imuSpikeFiltered,
|
||||
severity: FieldTestSeverity.warning,
|
||||
message: 'IMU Vibration Spike Filtered: Raw ${longitudinalG.toStringAsFixed(2)}G -> Filtered ${filteredLongitudinalG.toStringAsFixed(2)}G (Δ ${delta.toStringAsFixed(2)}G)',
|
||||
data: {
|
||||
'rawLongitudinalG': longitudinalG,
|
||||
'filteredLongitudinalG': filteredLongitudinalG,
|
||||
'delta': delta,
|
||||
},
|
||||
timestamp: now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (_activeSessionId != null) {
|
||||
_imuBatch.add(
|
||||
FieldTestImuRecordsCompanion.insert(
|
||||
sessionId: _activeSessionId!,
|
||||
timestamp: now,
|
||||
accelerometerX: drift.Value(accelX),
|
||||
accelerometerY: drift.Value(accelY),
|
||||
accelerometerZ: drift.Value(accelZ),
|
||||
gyroX: drift.Value(gyroX),
|
||||
gyroY: drift.Value(gyroY),
|
||||
gyroZ: drift.Value(gyroZ),
|
||||
roll: drift.Value(roll),
|
||||
pitch: drift.Value(pitch),
|
||||
longitudinalG: drift.Value(longitudinalG),
|
||||
lateralG: drift.Value(lateralG),
|
||||
filteredLongitudinalG: drift.Value(filteredLongitudinalG),
|
||||
filteredLateralG: drift.Value(filteredLateralG),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void logRoadAlertEvent({
|
||||
required FieldTestEventType type,
|
||||
required String providerId,
|
||||
required String category,
|
||||
required String title,
|
||||
double? distanceMeters,
|
||||
}) {
|
||||
_roadAlertsReceived++;
|
||||
if (category.toLowerCase().contains('police')) _policeAlerts++;
|
||||
if (category.toLowerCase().contains('camera')) _speedCameraAlerts++;
|
||||
if (category.toLowerCase().contains('accident') || category.toLowerCase().contains('hazard')) _accidentHazardAlerts++;
|
||||
|
||||
logEvent(
|
||||
eventType: type,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'Road Alert [$providerId]: $category - $title (${distanceMeters?.toStringAsFixed(0) ?? "?"}m)',
|
||||
data: {
|
||||
'provider': providerId,
|
||||
'category': category,
|
||||
'title': title,
|
||||
'distanceMeters': distanceMeters,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void logProviderFailure(String providerId, String error) {
|
||||
_providerFailures++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.roadAlertReceived,
|
||||
severity: FieldTestSeverity.warning,
|
||||
message: 'Road Alert Provider [$providerId] Error: $error',
|
||||
data: {'provider': providerId, 'error': error},
|
||||
);
|
||||
}
|
||||
|
||||
void logTts({required bool success, required String phrase, String? error}) {
|
||||
_ttsRequests++;
|
||||
if (success) {
|
||||
_ttsSuccesses++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.ttsSuccess,
|
||||
severity: FieldTestSeverity.debug,
|
||||
message: 'TTS Spoke: "$phrase"',
|
||||
);
|
||||
} else {
|
||||
_ttsFailures++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.ttsFailure,
|
||||
severity: FieldTestSeverity.warning,
|
||||
message: 'TTS Speech Failed for "$phrase": ${error ?? "Unknown error"}',
|
||||
data: {'phrase': phrase, 'error': error},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void logLifecycle(String stateName) {
|
||||
_lifecycleEvents++;
|
||||
logEvent(
|
||||
eventType: stateName.contains('Background') ? FieldTestEventType.appBackgrounded : FieldTestEventType.appForegrounded,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'App Lifecycle Transition: $stateName',
|
||||
);
|
||||
}
|
||||
|
||||
void logInterruptedRideFound(int count) {
|
||||
_interruptedRideEvents++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.rideInterrupted,
|
||||
severity: FieldTestSeverity.warning,
|
||||
message: 'Detected $count unfinished/interrupted ride(s) from previous session',
|
||||
);
|
||||
}
|
||||
|
||||
void logRideRecovered(int rideId) {
|
||||
_recoveryEvents++;
|
||||
logEvent(
|
||||
eventType: FieldTestEventType.rideRecovery,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'Successfully recovered and closed interrupted Ride #$rideId',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> flush() async {
|
||||
if (_isDisposed) return;
|
||||
if (_eventBatch.isEmpty && _gpsBatch.isEmpty && _imuBatch.isEmpty) return;
|
||||
|
||||
final start = DateTime.now();
|
||||
_dbBatchCount++;
|
||||
|
||||
final eventsToInsert = List<FieldTestEventsCompanion>.from(_eventBatch);
|
||||
final gpsToInsert = List<FieldTestGpsRecordsCompanion>.from(_gpsBatch);
|
||||
final imuToInsert = List<FieldTestImuRecordsCompanion>.from(_imuBatch);
|
||||
|
||||
_eventBatch.clear();
|
||||
_gpsBatch.clear();
|
||||
_imuBatch.clear();
|
||||
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
if (eventsToInsert.isNotEmpty) batch.insertAll(_db.fieldTestEvents, eventsToInsert);
|
||||
if (gpsToInsert.isNotEmpty) batch.insertAll(_db.fieldTestGpsRecords, gpsToInsert);
|
||||
if (imuToInsert.isNotEmpty) batch.insertAll(_db.fieldTestImuRecords, imuToInsert);
|
||||
});
|
||||
|
||||
final delayMs = DateTime.now().difference(start).inMilliseconds.toDouble();
|
||||
if (delayMs > _maxFlushDelayMs) {
|
||||
_maxFlushDelayMs = delayMs;
|
||||
}
|
||||
} catch (e) {
|
||||
_dbBatchFailures++;
|
||||
if (kDebugMode) debugPrint('[FieldTestLogger] Batch flush error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
SystemHealthSummary computeHealthSummary() {
|
||||
// 1. GPS Health
|
||||
HealthScoreCategory gpsH = HealthScoreCategory.good;
|
||||
final rejectRate = _gpsFixCount > 0 ? (_gpsRejectedCount / _gpsFixCount) : 0.0;
|
||||
if (_gpsOutageCount > 3 || rejectRate > 0.15) {
|
||||
gpsH = HealthScoreCategory.warning;
|
||||
} else if (_gpsOutageCount > 0 || rejectRate > 0.05) {
|
||||
gpsH = HealthScoreCategory.degraded;
|
||||
}
|
||||
|
||||
// 2. Sensors Health
|
||||
HealthScoreCategory sensorsH = HealthScoreCategory.good;
|
||||
if (_imuSpikeFilteredCount > 25) {
|
||||
sensorsH = HealthScoreCategory.warning;
|
||||
} else if (_imuSpikeFilteredCount > 5) {
|
||||
sensorsH = HealthScoreCategory.degraded;
|
||||
}
|
||||
|
||||
// 3. Persistence Health
|
||||
HealthScoreCategory persistenceH = HealthScoreCategory.good;
|
||||
if (_dbBatchFailures > 0) {
|
||||
persistenceH = HealthScoreCategory.critical;
|
||||
} else if (_maxFlushDelayMs > 1000) {
|
||||
persistenceH = HealthScoreCategory.warning;
|
||||
}
|
||||
|
||||
// 4. Lifecycle Health
|
||||
HealthScoreCategory lifecycleH = HealthScoreCategory.good;
|
||||
if (_interruptedRideEvents > 0) {
|
||||
lifecycleH = HealthScoreCategory.warning;
|
||||
}
|
||||
|
||||
// 5. Road Alerts Health
|
||||
HealthScoreCategory alertsH = HealthScoreCategory.good;
|
||||
if (_providerFailures > 2) {
|
||||
alertsH = HealthScoreCategory.warning;
|
||||
} else if (_providerFailures > 0) {
|
||||
alertsH = HealthScoreCategory.degraded;
|
||||
}
|
||||
|
||||
// 6. TTS Health
|
||||
HealthScoreCategory ttsH = HealthScoreCategory.good;
|
||||
if (_ttsFailures > 2) {
|
||||
ttsH = HealthScoreCategory.warning;
|
||||
} else if (_ttsFailures > 0) {
|
||||
ttsH = HealthScoreCategory.degraded;
|
||||
}
|
||||
|
||||
return SystemHealthSummary(
|
||||
gpsHealth: gpsH,
|
||||
sensorsHealth: sensorsH,
|
||||
persistenceHealth: persistenceH,
|
||||
lifecycleHealth: lifecycleH,
|
||||
roadAlertsHealth: alertsH,
|
||||
ttsHealth: ttsH,
|
||||
totalAnomalies: _anomalyCount,
|
||||
criticalCount: _dbBatchFailures,
|
||||
warningCount: _anomalyCount - _dbBatchFailures,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enforceRetention({int maxSessions = 10}) async {
|
||||
try {
|
||||
final sessions = await (_db.select(_db.fieldTestSessions)
|
||||
..orderBy([(s) => drift.OrderingTerm(expression: s.startedAt, mode: drift.OrderingMode.desc)]))
|
||||
.get();
|
||||
|
||||
if (sessions.length > maxSessions) {
|
||||
final toDelete = sessions.sublist(maxSessions);
|
||||
for (final s in toDelete) {
|
||||
// Never delete the currently active session
|
||||
if (s.id != _activeSessionId) {
|
||||
await deleteSession(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> deleteSession(int sessionId) async {
|
||||
if (sessionId == _activeSessionId) return false; // Block active deletion
|
||||
try {
|
||||
await _db.transaction(() async {
|
||||
await (_db.delete(_db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId))).go();
|
||||
await (_db.delete(_db.fieldTestGpsRecords)..where((g) => g.sessionId.equals(sessionId))).go();
|
||||
await (_db.delete(_db.fieldTestImuRecords)..where((i) => i.sessionId.equals(sessionId))).go();
|
||||
await (_db.delete(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).go();
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> clearAllSessions() async {
|
||||
try {
|
||||
await _db.transaction(() async {
|
||||
if (_activeSessionId != null) {
|
||||
// Keep active session data only
|
||||
await (_db.delete(_db.fieldTestEvents)..where((e) => e.sessionId.isNotValue(_activeSessionId!))).go();
|
||||
await (_db.delete(_db.fieldTestGpsRecords)..where((g) => g.sessionId.isNotValue(_activeSessionId!))).go();
|
||||
await (_db.delete(_db.fieldTestImuRecords)..where((i) => i.sessionId.isNotValue(_activeSessionId!))).go();
|
||||
await (_db.delete(_db.fieldTestSessions)..where((s) => s.id.isNotValue(_activeSessionId!))).go();
|
||||
} else {
|
||||
await _db.delete(_db.fieldTestEvents).go();
|
||||
await _db.delete(_db.fieldTestGpsRecords).go();
|
||||
await _db.delete(_db.fieldTestImuRecords).go();
|
||||
await _db.delete(_db.fieldTestSessions).go();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetMetrics() {
|
||||
_totalEvents = 0;
|
||||
_anomalyCount = 0;
|
||||
_gpsFixCount = 0;
|
||||
_gpsRejectedCount = 0;
|
||||
_gpsOutageCount = 0;
|
||||
_lastGpsLossTime = null;
|
||||
_totalGpsOutageDurationSeconds = 0.0;
|
||||
_longestGpsOutageSeconds = 0.0;
|
||||
_gpsBaselineResetCount = 0;
|
||||
_imuSampleCount = 0;
|
||||
_imuSpikeFilteredCount = 0;
|
||||
_dbBatchCount = 0;
|
||||
_dbBatchFailures = 0;
|
||||
_maxFlushDelayMs = 0.0;
|
||||
_roadAlertsReceived = 0;
|
||||
_policeAlerts = 0;
|
||||
_speedCameraAlerts = 0;
|
||||
_accidentHazardAlerts = 0;
|
||||
_providerFailures = 0;
|
||||
_ttsRequests = 0;
|
||||
_ttsSuccesses = 0;
|
||||
_ttsFailures = 0;
|
||||
_lifecycleEvents = 0;
|
||||
_interruptedRideEvents = 0;
|
||||
_recoveryEvents = 0;
|
||||
}
|
||||
|
||||
bool _isDisposed = false;
|
||||
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_flushTimer?.cancel();
|
||||
_liveLogStreamController.close();
|
||||
}
|
||||
}
|
||||
|
||||
class FieldTestEventItem {
|
||||
final DateTime timestamp;
|
||||
final FieldTestEventType eventType;
|
||||
final FieldTestSeverity severity;
|
||||
final String message;
|
||||
final String? dataJson;
|
||||
|
||||
FieldTestEventItem({
|
||||
required this.timestamp,
|
||||
required this.eventType,
|
||||
required this.severity,
|
||||
required this.message,
|
||||
this.dataJson,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
enum FieldTestEventType {
|
||||
rideStart,
|
||||
rideEnd,
|
||||
gpsFix,
|
||||
gpsHealthChanged,
|
||||
gpsLost,
|
||||
gpsRecovered,
|
||||
gpsBaselineReset,
|
||||
gpsOutlierRejected,
|
||||
speedZeroSnap,
|
||||
speedOutlier,
|
||||
speedChanged,
|
||||
imuSample,
|
||||
imuAnomaly,
|
||||
imuSpikeFiltered,
|
||||
calibrationLoaded,
|
||||
calibrationChanged,
|
||||
gForcePeak,
|
||||
leanPeak,
|
||||
telemetryStarted,
|
||||
telemetryStopped,
|
||||
telemetryBatchFlushed,
|
||||
telemetryPersistenceError,
|
||||
roadAlertReceived,
|
||||
roadAlertDeduplicated,
|
||||
roadAlertExpired,
|
||||
ttsRequested,
|
||||
ttsSuccess,
|
||||
ttsFailure,
|
||||
databaseWrite,
|
||||
databaseWriteFailure,
|
||||
appPaused,
|
||||
appResumed,
|
||||
appBackgrounded,
|
||||
appForegrounded,
|
||||
rideRecovery,
|
||||
rideInterrupted,
|
||||
thermalWarning,
|
||||
memoryWarning,
|
||||
loggerStarted,
|
||||
loggerStopped,
|
||||
loggerFlush,
|
||||
loggerError,
|
||||
}
|
||||
|
||||
enum FieldTestSeverity {
|
||||
debug,
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
critical,
|
||||
}
|
||||
|
||||
extension FieldTestSeverityExtension on FieldTestSeverity {
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case FieldTestSeverity.debug:
|
||||
return Colors.grey;
|
||||
case FieldTestSeverity.info:
|
||||
return Colors.cyanAccent;
|
||||
case FieldTestSeverity.warning:
|
||||
return Colors.amberAccent;
|
||||
case FieldTestSeverity.error:
|
||||
return Colors.orangeAccent;
|
||||
case FieldTestSeverity.critical:
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
|
||||
String get label => name.toUpperCase();
|
||||
}
|
||||
|
||||
enum HealthScoreCategory {
|
||||
good,
|
||||
degraded,
|
||||
warning,
|
||||
critical,
|
||||
}
|
||||
|
||||
extension HealthScoreCategoryExtension on HealthScoreCategory {
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case HealthScoreCategory.good:
|
||||
return Colors.greenAccent;
|
||||
case HealthScoreCategory.degraded:
|
||||
return Colors.cyanAccent;
|
||||
case HealthScoreCategory.warning:
|
||||
return Colors.amberAccent;
|
||||
case HealthScoreCategory.critical:
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
|
||||
String get label => name.toUpperCase();
|
||||
}
|
||||
|
||||
class SystemHealthSummary {
|
||||
final HealthScoreCategory gpsHealth;
|
||||
final HealthScoreCategory sensorsHealth;
|
||||
final HealthScoreCategory persistenceHealth;
|
||||
final HealthScoreCategory lifecycleHealth;
|
||||
final HealthScoreCategory roadAlertsHealth;
|
||||
final HealthScoreCategory ttsHealth;
|
||||
final int totalAnomalies;
|
||||
final int criticalCount;
|
||||
final int warningCount;
|
||||
|
||||
const SystemHealthSummary({
|
||||
required this.gpsHealth,
|
||||
required this.sensorsHealth,
|
||||
required this.persistenceHealth,
|
||||
required this.lifecycleHealth,
|
||||
required this.roadAlertsHealth,
|
||||
required this.ttsHealth,
|
||||
required this.totalAnomalies,
|
||||
required this.criticalCount,
|
||||
required this.warningCount,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'gpsHealth': gpsHealth.name,
|
||||
'sensorsHealth': sensorsHealth.name,
|
||||
'persistenceHealth': persistenceHealth.name,
|
||||
'lifecycleHealth': lifecycleHealth.name,
|
||||
'roadAlertsHealth': roadAlertsHealth.name,
|
||||
'ttsHealth': ttsHealth.name,
|
||||
'totalAnomalies': totalAnomalies,
|
||||
'criticalCount': criticalCount,
|
||||
'warningCount': warningCount,
|
||||
};
|
||||
|
||||
factory SystemHealthSummary.fromJson(Map<String, dynamic> json) {
|
||||
return SystemHealthSummary(
|
||||
gpsHealth: HealthScoreCategory.values.byName(json['gpsHealth'] ?? 'good'),
|
||||
sensorsHealth: HealthScoreCategory.values.byName(json['sensorsHealth'] ?? 'good'),
|
||||
persistenceHealth: HealthScoreCategory.values.byName(json['persistenceHealth'] ?? 'good'),
|
||||
lifecycleHealth: HealthScoreCategory.values.byName(json['lifecycleHealth'] ?? 'good'),
|
||||
roadAlertsHealth: HealthScoreCategory.values.byName(json['roadAlertsHealth'] ?? 'good'),
|
||||
ttsHealth: HealthScoreCategory.values.byName(json['ttsHealth'] ?? 'good'),
|
||||
totalAnomalies: json['totalAnomalies'] ?? 0,
|
||||
criticalCount: json['criticalCount'] ?? 0,
|
||||
warningCount: json['warningCount'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnomalyEvent {
|
||||
final DateTime timestamp;
|
||||
final FieldTestEventType type;
|
||||
final FieldTestSeverity severity;
|
||||
final String title;
|
||||
final String details;
|
||||
|
||||
const AnomalyEvent({
|
||||
required this.timestamp,
|
||||
required this.type,
|
||||
required this.severity,
|
||||
required this.title,
|
||||
required this.details,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
final locationServiceProvider = Provider<LocationService>((ref) {
|
||||
return LocationService();
|
||||
final service = LocationService();
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
|
||||
final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||
|
|
@ -12,83 +15,331 @@ final locationStreamProvider = StreamProvider<Position>((ref) {
|
|||
return service.getFilteredPositionStream();
|
||||
});
|
||||
|
||||
final locationPermissionGrantedProvider = FutureProvider<bool>((ref) async {
|
||||
final service = ref.watch(locationServiceProvider);
|
||||
return await service.hasPermissions();
|
||||
});
|
||||
|
||||
class LocationService {
|
||||
Position? _lastValidPosition;
|
||||
double? _emaSpeed;
|
||||
|
||||
final StreamController<Position> _controller = StreamController<Position>.broadcast();
|
||||
StreamSubscription<Position>? _rawPositionSub;
|
||||
Timer? _watchdogTimer;
|
||||
bool _isDisposed = false;
|
||||
|
||||
// Filtering thresholds
|
||||
static const double _maxRealisticSpeed = 100.0; // roughly 223 MPH in m/s
|
||||
static const double _maxAcceleration = 20.0; // m/s^2 (unrealistic jump)
|
||||
static const double _speedEmaAlpha = 0.3; // Smoothing factor for speed
|
||||
static const double _maxAcceleration = 30.0; // m/s^2 (allows extreme superbike launch / braking while rejecting GPS teleportation)
|
||||
static const double _speedEmaAlpha = 0.35; // Smoothing factor for speed
|
||||
|
||||
void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic;
|
||||
|
||||
LocationService() {
|
||||
_initStream();
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
final status = await Permission.locationWhenInUse.request();
|
||||
if (status.isGranted) {
|
||||
try {
|
||||
// 1. Geolocator native permission check & request
|
||||
LocationPermission geoPermission = await Geolocator.checkPermission();
|
||||
if (geoPermission == LocationPermission.denied) {
|
||||
geoPermission = await Geolocator.requestPermission();
|
||||
}
|
||||
|
||||
// 2. Permission handler check & request
|
||||
final locStatus = await Permission.locationWhenInUse.request();
|
||||
await Permission.notification.request();
|
||||
|
||||
if (geoPermission == LocationPermission.whileInUse ||
|
||||
geoPermission == LocationPermission.always ||
|
||||
locStatus.isGranted) {
|
||||
try {
|
||||
await Permission.locationAlways.request();
|
||||
} catch (_) {}
|
||||
// Restart/refresh GPS stream now that permissions are granted
|
||||
_startListening();
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
Stream<Position> getFilteredPositionStream() {
|
||||
final LocationSettings locationSettings = AndroidSettings(
|
||||
accuracy: LocationAccuracy.bestForNavigation,
|
||||
distanceFilter: 0,
|
||||
intervalDuration: const Duration(milliseconds: 200), // 5Hz updates
|
||||
);
|
||||
Future<bool> hasPermissions() async {
|
||||
try {
|
||||
final geoPermission = await Geolocator.checkPermission();
|
||||
if (geoPermission == LocationPermission.whileInUse ||
|
||||
geoPermission == LocationPermission.always) {
|
||||
return true;
|
||||
}
|
||||
final status = await Permission.locationWhenInUse.status;
|
||||
return status.isGranted;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return Geolocator.getPositionStream(locationSettings: locationSettings)
|
||||
.where(_isValidPosition)
|
||||
.map(_applyEmaFilter);
|
||||
Stream<Position> getFilteredPositionStream() {
|
||||
return _controller.stream;
|
||||
}
|
||||
|
||||
void _initStream() {
|
||||
_startListening();
|
||||
_startWatchdog();
|
||||
}
|
||||
|
||||
Future<void> _startListening() async {
|
||||
if (_isDisposed) return;
|
||||
await _rawPositionSub?.cancel();
|
||||
_rawPositionSub = null;
|
||||
|
||||
try {
|
||||
final isServiceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!isServiceEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Immediately bootstrap with last known position if available
|
||||
final lastPos = await Geolocator.getLastKnownPosition();
|
||||
if (lastPos != null && !_isDisposed) {
|
||||
_handleRawPosition(lastPos);
|
||||
}
|
||||
|
||||
// Platform-specific LocationSettings
|
||||
LocationSettings locationSettings;
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
locationSettings = AndroidSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 0,
|
||||
intervalDuration: const Duration(milliseconds: 200),
|
||||
forceLocationManager: true, // Directly access GPS hardware receiver
|
||||
);
|
||||
} else {
|
||||
locationSettings = const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 0,
|
||||
);
|
||||
}
|
||||
|
||||
_rawPositionSub = Geolocator.getPositionStream(locationSettings: locationSettings).listen(
|
||||
(pos) {
|
||||
_handleRawPosition(pos);
|
||||
},
|
||||
onError: (err) {
|
||||
onGpsDiagnostic?.call(
|
||||
Position(
|
||||
longitude: _lastValidPosition?.longitude ?? 0.0,
|
||||
latitude: _lastValidPosition?.latitude ?? 0.0,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 999.0,
|
||||
altitude: 0.0,
|
||||
heading: 0.0,
|
||||
speed: 0.0,
|
||||
speedAccuracy: 0.0,
|
||||
altitudeAccuracy: 0.0,
|
||||
headingAccuracy: 0.0,
|
||||
),
|
||||
false,
|
||||
'GPS Stream Error: $err',
|
||||
);
|
||||
// Try to reconnect in 2 seconds
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (!_isDisposed) _startListening();
|
||||
});
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _startWatchdog() {
|
||||
_watchdogTimer?.cancel();
|
||||
// Watchdog every 1000ms: if stream has gone quiet (>2s since last fix), poll getCurrentPosition actively
|
||||
_watchdogTimer = Timer.periodic(const Duration(milliseconds: 1000), (_) async {
|
||||
if (_isDisposed) return;
|
||||
final now = DateTime.now();
|
||||
final lastFixAge = _lastValidPosition == null
|
||||
? 999
|
||||
: now.difference(_lastValidPosition!.timestamp).inMilliseconds;
|
||||
|
||||
if (lastFixAge > 2000) {
|
||||
try {
|
||||
final hasPerm = await hasPermissions();
|
||||
if (!hasPerm) return;
|
||||
|
||||
Position? pos;
|
||||
try {
|
||||
pos = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
timeLimit: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
if (pos != null && !_isDisposed) {
|
||||
_handleRawPosition(pos);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleRawPosition(Position position) {
|
||||
if (_isValidPosition(position)) {
|
||||
final filtered = _applyEmaFilter(position);
|
||||
if (!_controller.isClosed) {
|
||||
_controller.add(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isValidPosition(Position position) {
|
||||
// Dynamic accuracy threshold: allow worse accuracy at higher speeds
|
||||
// Dynamic accuracy threshold: allow wider accuracy during satellite lock & highway speeds
|
||||
final currentSpeed = _emaSpeed ?? position.speed;
|
||||
final maxAcceptableAccuracy = currentSpeed > 20.0 ? 30.0 : 15.0;
|
||||
final maxAcceptableAccuracy = _lastValidPosition == null
|
||||
? 80.0
|
||||
: (currentSpeed > 10.0 ? 60.0 : 45.0);
|
||||
|
||||
if (position.accuracy > maxAcceptableAccuracy) {
|
||||
onGpsDiagnostic?.call(
|
||||
position,
|
||||
false,
|
||||
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return false; // Reject extremely poor accuracy
|
||||
}
|
||||
|
||||
// Reject 0.0 MPH glitch if accuracy is degraded while motorcycle was previously at speed
|
||||
if (position.speed == 0.0 && currentSpeed > 10.0 && position.accuracy > 30.0) {
|
||||
onGpsDiagnostic?.call(
|
||||
position,
|
||||
false,
|
||||
'Transient zero-speed cell glitch rejected (${position.accuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (position.speed > _maxRealisticSpeed) {
|
||||
return false; // Reject impossible speeds
|
||||
onGpsDiagnostic?.call(
|
||||
position,
|
||||
false,
|
||||
'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)',
|
||||
);
|
||||
return false; // Reject impossible speeds (>223 MPH)
|
||||
}
|
||||
|
||||
if (_lastValidPosition != null) {
|
||||
final timeDiff = (position.timestamp.difference(_lastValidPosition!.timestamp)).inMilliseconds / 1000.0;
|
||||
if (timeDiff > 0) {
|
||||
// Acceleration check
|
||||
|
||||
// If signal was lost for > 10 seconds (e.g. tunnel or stop), treat as new baseline fix
|
||||
if (timeDiff > 10.0) {
|
||||
_lastValidPosition = position;
|
||||
_emaSpeed = position.speed;
|
||||
onGpsDiagnostic?.call(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (timeDiff >= 0.25) {
|
||||
// Acceleration check (only compute over stable time windows >= 250ms)
|
||||
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
|
||||
if (accel > _maxAcceleration) {
|
||||
onGpsDiagnostic?.call(
|
||||
position,
|
||||
false,
|
||||
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
|
||||
);
|
||||
return false; // Reject impossible acceleration spikes
|
||||
}
|
||||
|
||||
// Path consistency check (distance jump vs time)
|
||||
final distanceJump = Geolocator.distanceBetween(
|
||||
_lastValidPosition!.latitude, _lastValidPosition!.longitude,
|
||||
position.latitude, position.longitude
|
||||
_lastValidPosition!.latitude,
|
||||
_lastValidPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
// If distance jumped implies speed > max realistic speed + some buffer, reject
|
||||
final impliedSpeed = distanceJump / timeDiff;
|
||||
if (impliedSpeed > _maxRealisticSpeed * 1.5) {
|
||||
onGpsDiagnostic?.call(
|
||||
position,
|
||||
false,
|
||||
'Position jump anomaly (${(impliedSpeed * 2.23694).toStringAsFixed(1)} MPH implied speed)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lastValidPosition = position;
|
||||
onGpsDiagnostic?.call(position, true, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isStationaryLocked = true;
|
||||
Position? _stationaryAnchorPosition;
|
||||
DateTime? _lowSpeedStartTime;
|
||||
|
||||
Position _applyEmaFilter(Position position) {
|
||||
if (_emaSpeed == null) {
|
||||
_emaSpeed = position.speed;
|
||||
} else {
|
||||
_emaSpeed = _speedEmaAlpha * position.speed + (1 - _speedEmaAlpha) * _emaSpeed!;
|
||||
// Robust stationary hysteresis filter:
|
||||
// GPS Doppler noise and multipath jitter typically fluctuates between 0.2 m/s and 0.7 m/s (0.5 - 1.6 MPH)
|
||||
// while the motorcycle is completely stationary.
|
||||
final rawSpeed = position.speed;
|
||||
|
||||
if (rawSpeed < 0.6) {
|
||||
_lowSpeedStartTime ??= position.timestamp;
|
||||
final lowSpeedDuration = position.timestamp.difference(_lowSpeedStartTime!).inMilliseconds;
|
||||
|
||||
_stationaryAnchorPosition ??= position;
|
||||
|
||||
final driftFromAnchor = Geolocator.distanceBetween(
|
||||
_stationaryAnchorPosition!.latitude,
|
||||
_stationaryAnchorPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
// Lock to absolute 0.0 MPH if below threshold for > 800ms or within 2.5m drift radius
|
||||
if (lowSpeedDuration >= 800 || driftFromAnchor < 2.5) {
|
||||
_isStationaryLocked = true;
|
||||
_emaSpeed = 0.0;
|
||||
}
|
||||
} else if (rawSpeed >= 0.8) {
|
||||
// Break out of stationary lock only upon genuine sustained movement
|
||||
double moveDistance = 0.0;
|
||||
if (_stationaryAnchorPosition != null) {
|
||||
moveDistance = Geolocator.distanceBetween(
|
||||
_stationaryAnchorPosition!.latitude,
|
||||
_stationaryAnchorPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
if (!_isStationaryLocked || moveDistance >= 2.0 || rawSpeed >= 1.2) {
|
||||
_isStationaryLocked = false;
|
||||
_stationaryAnchorPosition = null;
|
||||
_lowSpeedStartTime = null;
|
||||
|
||||
if (_emaSpeed == null || _emaSpeed == 0.0) {
|
||||
_emaSpeed = rawSpeed;
|
||||
} else {
|
||||
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
|
||||
}
|
||||
} else {
|
||||
_emaSpeed = 0.0;
|
||||
}
|
||||
} else {
|
||||
// Transition zone (0.6 - 0.8 m/s): maintain current stationary lock state
|
||||
if (_isStationaryLocked) {
|
||||
_emaSpeed = 0.0;
|
||||
} else {
|
||||
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
|
||||
}
|
||||
}
|
||||
|
||||
// Return a new Position with the smoothed speed
|
||||
return Position(
|
||||
longitude: position.longitude,
|
||||
latitude: position.latitude,
|
||||
|
|
@ -96,7 +347,7 @@ class LocationService {
|
|||
accuracy: position.accuracy,
|
||||
altitude: position.altitude,
|
||||
heading: position.heading,
|
||||
speed: _emaSpeed!,
|
||||
speed: _emaSpeed ?? 0.0,
|
||||
speedAccuracy: position.speedAccuracy,
|
||||
altitudeAccuracy: position.altitudeAccuracy,
|
||||
headingAccuracy: position.headingAccuracy,
|
||||
|
|
@ -104,4 +355,11 @@ class LocationService {
|
|||
floor: position.floor,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_watchdogTimer?.cancel();
|
||||
_rawPositionSub?.cancel();
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import 'map_provider_interface.dart';
|
|||
|
||||
class OpenStreetMapProvider implements AppMapProvider {
|
||||
final MapController _mapController = MapController();
|
||||
ll.LatLng? _lastFollowPos;
|
||||
DateTime? _lastFollowTime;
|
||||
|
||||
@override
|
||||
AppMapType get type => AppMapType.openStreetMap;
|
||||
|
|
@ -28,12 +30,23 @@ class OpenStreetMapProvider implements AppMapProvider {
|
|||
final centerLl = ll.LatLng(center.latitude, center.longitude);
|
||||
|
||||
if (isFollowMode) {
|
||||
final now = DateTime.now();
|
||||
final shouldMove = _lastFollowPos == null ||
|
||||
_lastFollowTime == null ||
|
||||
now.difference(_lastFollowTime!).inMilliseconds > 400 ||
|
||||
(centerLl.latitude - _lastFollowPos!.latitude).abs() > 0.00005 ||
|
||||
(centerLl.longitude - _lastFollowPos!.longitude).abs() > 0.00005;
|
||||
|
||||
if (shouldMove) {
|
||||
_lastFollowPos = centerLl;
|
||||
_lastFollowTime = now;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
_mapController.move(centerLl, zoom);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return FlutterMap(
|
||||
mapController: _mapController,
|
||||
|
|
|
|||
|
|
@ -108,6 +108,137 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
|||
source: 'Yamaha Motor Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
'yamaha yzf-r6': MotorcycleSpecification(
|
||||
year: 2001,
|
||||
manufacturer: 'Yamaha',
|
||||
model: 'YZF-R6',
|
||||
category: 'Supersport',
|
||||
displacementCc: 599,
|
||||
engineConfiguration: 'Inline-4 DOHC 16-Valve',
|
||||
cylinderCount: 4,
|
||||
boreMm: 65.5,
|
||||
strokeMm: 44.5,
|
||||
compressionRatio: '12.4:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: '4x 37mm Keihin CVRD37 with TPS',
|
||||
fuelType: 'Premium Unleaded',
|
||||
valveConfiguration: 'DOHC, 4 valves per cylinder',
|
||||
horsepower: 120.0,
|
||||
horsepowerRpm: 13000,
|
||||
torqueLbFt: 50.2,
|
||||
torqueRpm: 11500,
|
||||
redlineRpm: 15500,
|
||||
transmissionType: 'Manual 6-Speed',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 414.0,
|
||||
dryWeightLbs: 368.0,
|
||||
seatHeightInches: 32.3,
|
||||
wheelbaseInches: 54.3,
|
||||
fuelCapacityGallons: 4.49,
|
||||
fuelEconomyMpg: 40.0,
|
||||
estimatedRangeMiles: 175.0,
|
||||
frameType: 'Aluminum Deltabox II',
|
||||
frontSuspension: '43mm telescopic fork, fully adjustable (130mm travel)',
|
||||
rearSuspension: 'Monoshock with piggyback reservoir, fully adjustable (120mm travel)',
|
||||
frontBrake: 'Dual 298mm floating discs with 4-piston monobloc calipers',
|
||||
rearBrake: 'Single 220mm disc with single-piston caliper',
|
||||
absType: 'Non-ABS',
|
||||
frontTireSize: '120/60ZR17',
|
||||
rearTireSize: '180/55ZR17',
|
||||
claimedTopSpeedMph: 160.0,
|
||||
claimedZeroToSixtySec: 3.0,
|
||||
claimedQuarterMileSec: 10.7,
|
||||
source: 'Yamaha Motor Official Factory Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
'yamaha yzf-r7': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Yamaha',
|
||||
model: 'YZF-R7',
|
||||
category: 'Supersport',
|
||||
displacementCc: 689,
|
||||
engineConfiguration: 'Crossplane CP2 Parallel-Twin',
|
||||
cylinderCount: 2,
|
||||
boreMm: 80.0,
|
||||
strokeMm: 68.6,
|
||||
compressionRatio: '11.5:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: 'Electronic Fuel Injection',
|
||||
fuelType: 'Premium Unleaded',
|
||||
valveConfiguration: 'DOHC, 4 valves per cylinder',
|
||||
horsepower: 73.4,
|
||||
horsepowerRpm: 8750,
|
||||
torqueLbFt: 49.4,
|
||||
torqueRpm: 6500,
|
||||
redlineRpm: 10000,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 414.0,
|
||||
dryWeightLbs: 388.0,
|
||||
seatHeightInches: 32.9,
|
||||
wheelbaseInches: 54.9,
|
||||
fuelCapacityGallons: 3.4,
|
||||
fuelEconomyMpg: 58.0,
|
||||
estimatedRangeMiles: 195.0,
|
||||
frameType: 'High-tensile steel tube frame with aluminum center brace',
|
||||
frontSuspension: '41mm KYB inverted fork, fully adjustable',
|
||||
rearSuspension: 'Link-type KYB shock with adjustable preload and rebound',
|
||||
frontBrake: 'Dual 298mm discs with radial Brembo master cylinder',
|
||||
rearBrake: 'Single 245mm disc',
|
||||
absType: 'Dual-Channel ABS',
|
||||
frontTireSize: '120/70ZR17',
|
||||
rearTireSize: '180/55ZR17',
|
||||
claimedTopSpeedMph: 139.0,
|
||||
claimedZeroToSixtySec: 3.5,
|
||||
claimedQuarterMileSec: 11.9,
|
||||
source: 'Yamaha Motor Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
'yamaha yzf-r3': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Yamaha',
|
||||
model: 'YZF-R3',
|
||||
category: 'Lightweight Sport',
|
||||
displacementCc: 321,
|
||||
engineConfiguration: 'Parallel Twin DOHC',
|
||||
cylinderCount: 2,
|
||||
boreMm: 68.0,
|
||||
strokeMm: 44.1,
|
||||
compressionRatio: '11.2:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: 'Electronic Fuel Injection',
|
||||
fuelType: 'Regular Unleaded',
|
||||
horsepower: 42.0,
|
||||
horsepowerRpm: 10750,
|
||||
torqueLbFt: 21.8,
|
||||
torqueRpm: 9000,
|
||||
redlineRpm: 12500,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 375.0,
|
||||
dryWeightLbs: 348.0,
|
||||
seatHeightInches: 30.7,
|
||||
wheelbaseInches: 54.3,
|
||||
fuelCapacityGallons: 3.7,
|
||||
fuelEconomyMpg: 56.0,
|
||||
estimatedRangeMiles: 205.0,
|
||||
frameType: 'Steel Diamond Frame',
|
||||
frontSuspension: '37mm KYB inverted fork',
|
||||
rearSuspension: 'Single shock with preload adjustment',
|
||||
frontBrake: 'Single 298mm floating disc with 2-piston caliper',
|
||||
rearBrake: 'Single 220mm disc',
|
||||
absType: 'Dual-Channel ABS',
|
||||
frontTireSize: '110/70R17',
|
||||
rearTireSize: '140/70R17',
|
||||
claimedTopSpeedMph: 112.0,
|
||||
claimedZeroToSixtySec: 5.1,
|
||||
claimedQuarterMileSec: 13.9,
|
||||
source: 'Yamaha Motor Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
'yamaha yzf-r1': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Yamaha',
|
||||
|
|
@ -436,16 +567,30 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
|||
final modelUpper = selection.modelName.toUpperCase();
|
||||
final makeUpper = selection.makeName.toUpperCase();
|
||||
|
||||
// Extract displacement from model string (e.g., "Ninja 650" -> 650, "CBR1000RR" -> 1000, "890 Duke" -> 890)
|
||||
// Extract displacement from model string
|
||||
int displacement = 650;
|
||||
if (modelUpper.contains('R6') || modelUpper.contains('ZX-6') || modelUpper.contains('600RR') || modelUpper.contains('GSX-R600') || modelUpper.contains('636')) {
|
||||
displacement = modelUpper.contains('636') ? 636 : 599;
|
||||
} else if (modelUpper.contains('R1') || modelUpper.contains('ZX-10') || modelUpper.contains('1000RR') || modelUpper.contains('GSX-R1000') || modelUpper.contains('S1000RR')) {
|
||||
displacement = 998;
|
||||
} else if (modelUpper.contains('R7') || modelUpper.contains('MT-07') || modelUpper.contains('RS660')) {
|
||||
displacement = 689;
|
||||
} else if (modelUpper.contains('R3') || modelUpper.contains('MT-03') || modelUpper.contains('390') || modelUpper.contains('400')) {
|
||||
displacement = 399;
|
||||
} else if (modelUpper.contains('750')) {
|
||||
displacement = 749;
|
||||
} else if (modelUpper.contains('HAYABUSA') || modelUpper.contains('1300') || modelUpper.contains('ZX-14')) {
|
||||
displacement = 1340;
|
||||
} else {
|
||||
final numMatch = RegExp(r'(\d{3,4})').firstMatch(modelUpper);
|
||||
if (numMatch != null) {
|
||||
displacement = int.tryParse(numMatch.group(1)!) ?? 650;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine category
|
||||
String category = 'Street';
|
||||
if (modelUpper.contains('NINJA') || modelUpper.contains('CBR') || modelUpper.contains('GSX-R') || modelUpper.contains('YZF') || modelUpper.contains('PANIGALE') || modelUpper.contains('S1000RR') || modelUpper.contains('SUPERBIKE')) {
|
||||
if (modelUpper.contains('NINJA') || modelUpper.contains('CBR') || modelUpper.contains('GSX-R') || modelUpper.contains('YZF') || modelUpper.contains('PANIGALE') || modelUpper.contains('S1000RR') || modelUpper.contains('SUPERBIKE') || modelUpper.contains('R6') || modelUpper.contains('R1') || modelUpper.contains('R7')) {
|
||||
category = displacement >= 900 ? 'Superbike' : 'Supersport';
|
||||
} else if (modelUpper.contains('MT') || modelUpper.contains('DUKE') || modelUpper.contains('Z900') || modelUpper.contains('STREETFIGHTER') || modelUpper.contains('TRIDENT') || modelUpper.contains('TUONO')) {
|
||||
category = 'Hyper Naked';
|
||||
|
|
@ -458,7 +603,7 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
|||
// Calculate realistic specs based on displacement & category
|
||||
double hp = displacement * 0.12;
|
||||
if (category == 'Superbike' || category == 'Supersport') {
|
||||
hp = displacement * (displacement > 900 ? 0.20 : 0.19);
|
||||
hp = displacement == 599 ? 120.0 : (displacement >= 900 ? 198.0 : (displacement * 0.19));
|
||||
} else if (category == 'Hyper Naked') {
|
||||
hp = displacement * 0.135;
|
||||
} else if (category == 'Cruiser') {
|
||||
|
|
@ -490,8 +635,8 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
|||
variant: selection.variantName,
|
||||
category: category,
|
||||
displacementCc: displacement.toDouble(),
|
||||
engineConfiguration: displacement > 800 ? 'Inline Engine' : 'V-Twin / Parallel Twin',
|
||||
cylinderCount: displacement >= 900 ? 4 : (displacement >= 600 ? 3 : 2),
|
||||
engineConfiguration: (category == 'Supersport' || category == 'Superbike') ? 'Inline-4 DOHC' : (displacement > 800 ? 'Inline Engine' : 'V-Twin / Parallel Twin'),
|
||||
cylinderCount: (category == 'Supersport' || category == 'Superbike') ? 4 : (displacement >= 900 ? 4 : (displacement >= 600 ? 3 : 2)),
|
||||
compressionRatio: '11.8:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: 'Electronic Fuel Injection',
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ import 'dart:async';
|
|||
import 'dart:math' as math;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'calibration_service.dart';
|
||||
|
||||
final sensorFusionServiceProvider = Provider<SensorFusionService>((ref) {
|
||||
return SensorFusionService();
|
||||
final calibrationService = ref.watch(calibrationServiceProvider);
|
||||
final service = SensorFusionService(calibrationService);
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
|
||||
final leanAngleStreamProvider = StreamProvider<double>((ref) {
|
||||
|
|
@ -32,6 +36,8 @@ class GForceData {
|
|||
}
|
||||
|
||||
class SensorFusionService {
|
||||
final CalibrationService _calibrationService;
|
||||
|
||||
final _leanAngleController = StreamController<double>.broadcast();
|
||||
final _pitchAngleController = StreamController<double>.broadcast();
|
||||
final _gForceController = StreamController<GForceData>.broadcast();
|
||||
|
|
@ -46,73 +52,114 @@ class SensorFusionService {
|
|||
|
||||
// Calibration offsets
|
||||
double _rollOffset = 0.0;
|
||||
double _pitchOffset = 0.0;
|
||||
|
||||
// Sensor fusion state
|
||||
double _currentRoll = 0.0; // The lean angle
|
||||
double _currentPitch = 0.0;
|
||||
DateTime? _lastUpdate;
|
||||
DateTime? _lastGyroTime;
|
||||
|
||||
static const double _alpha = 0.96; // Complementary filter constant
|
||||
static const double _baseAlpha = 0.94; // Base complementary filter gain
|
||||
static const double _gravityConst = 9.80665;
|
||||
|
||||
SensorFusionService() {
|
||||
// Low-pass filter for G-Force to suppress motorcycle vibration spikes
|
||||
double _filteredLongitudinalG = 0.0;
|
||||
double _filteredLateralG = 0.0;
|
||||
static const double _gAlpha = 0.40; // Fast responsive dynamic smoothing
|
||||
|
||||
SensorFusionService(this._calibrationService) {
|
||||
_loadInitialCalibration();
|
||||
_startSensors();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialCalibration() async {
|
||||
try {
|
||||
final offsets = await _calibrationService.loadCalibrationOffsets();
|
||||
_pitchOffset = offsets['pitch'] ?? 0.0;
|
||||
_rollOffset = offsets['roll'] ?? 0.0;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _startSensors() {
|
||||
_gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) {
|
||||
final now = DateTime.now();
|
||||
if (_lastUpdate == null) {
|
||||
_lastUpdate = now;
|
||||
if (_lastGyroTime == null) {
|
||||
_lastGyroTime = now;
|
||||
return;
|
||||
}
|
||||
|
||||
final dt = now.difference(_lastUpdate!).inMicroseconds / 1000000.0;
|
||||
_lastUpdate = now;
|
||||
double dt = now.difference(_lastGyroTime!).inMicroseconds / 1000000.0;
|
||||
_lastGyroTime = now;
|
||||
|
||||
// Clamp dt to prevent runaway integration during gaps/app pauses
|
||||
if (dt > 0.1) dt = 0.1;
|
||||
if (dt <= 0.0) return;
|
||||
|
||||
// Integrate gyro (rad/s to degrees)
|
||||
// Phone X is pitch rate, Phone Y is roll rate, Phone Z is yaw rate
|
||||
final gx = event.x * (180.0 / math.pi);
|
||||
final gy = event.y * (180.0 / math.pi);
|
||||
|
||||
_currentPitch += gx * dt;
|
||||
_currentRoll -= gy * dt;
|
||||
_currentPitch = (_currentPitch + gx * dt).clamp(-90.0, 90.0);
|
||||
_currentRoll = (_currentRoll - gy * dt).clamp(-90.0, 90.0);
|
||||
});
|
||||
|
||||
_accelSub = accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((AccelerometerEvent event) {
|
||||
// Calculate roll/pitch from accelerometer (accounting for gravity)
|
||||
// Assuming phone is mounted roughly vertical or horizontal, we calculate basic roll
|
||||
// Calculate total acceleration magnitude
|
||||
final norm = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
|
||||
if (norm < 0.1) return;
|
||||
|
||||
// Compute static gravity-based angles
|
||||
final accelRoll = -math.atan2(event.x, math.sqrt(event.y * event.y + event.z * event.z)) * (180.0 / math.pi);
|
||||
final accelPitch = math.atan2(-event.y, event.z) * (180.0 / math.pi);
|
||||
|
||||
// Complementary filter: Combine integrated gyro with absolute accel
|
||||
if (_lastUpdate != null) {
|
||||
_currentRoll = _alpha * _currentRoll + (1.0 - _alpha) * accelRoll;
|
||||
_currentPitch = _alpha * _currentPitch + (1.0 - _alpha) * accelPitch;
|
||||
// Dynamic acceleration gating:
|
||||
// On a motorcycle, accelerometers measure specific force = gravity + vehicle dynamics.
|
||||
// During acceleration, braking, or cornering, the apparent gravity vector tilts significantly.
|
||||
// Inhibit accelerometer tilt correction when total force deviates from 1.0g or during motion.
|
||||
final gMag = norm / _gravityConst;
|
||||
final dynamicDelta = (gMag - 1.0).abs();
|
||||
|
||||
double calibratedLean = _currentRoll - _rollOffset;
|
||||
_leanAngleController.add(calibratedLean);
|
||||
_pitchAngleController.add(_currentPitch);
|
||||
// If dynamic force > 0.08g, trust gyro 99.8% to prevent vehicle acceleration from tilting lean/pitch
|
||||
double dynamicAlpha = _baseAlpha;
|
||||
if (dynamicDelta > 0.08) {
|
||||
dynamicAlpha = 0.998;
|
||||
} else if (dynamicDelta > 0.04) {
|
||||
dynamicAlpha = 0.985;
|
||||
}
|
||||
|
||||
_currentRoll = (dynamicAlpha * _currentRoll + (1.0 - dynamicAlpha) * accelRoll).clamp(-90.0, 90.0);
|
||||
_currentPitch = (dynamicAlpha * _currentPitch + (1.0 - dynamicAlpha) * accelPitch).clamp(-90.0, 90.0);
|
||||
|
||||
final calibratedLean = (_currentRoll - _rollOffset).clamp(-65.0, 65.0);
|
||||
final calibratedPitch = (_currentPitch - _pitchOffset).clamp(-45.0, 45.0);
|
||||
|
||||
_leanAngleController.add(calibratedLean);
|
||||
_pitchAngleController.add(calibratedPitch);
|
||||
});
|
||||
|
||||
_userAccelSub = userAccelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((UserAccelerometerEvent event) {
|
||||
// User accelerometer removes gravity.
|
||||
// Convert m/s^2 to G's
|
||||
const gravity = 9.80665;
|
||||
// Project phone linear acceleration vector into motorcycle body frame
|
||||
// using calibrated pitch and roll attitude angles
|
||||
final pitchRad = (_currentPitch - _pitchOffset) * (math.pi / 180.0);
|
||||
final rollRad = (_currentRoll - _rollOffset) * (math.pi / 180.0);
|
||||
|
||||
// We need to rotate these vectors by the current pitch/roll to get true longitudinal/lateral G's
|
||||
// relative to the motorcycle, but for a mounted phone, Y is typically longitudinal if vertical,
|
||||
// or Z if horizontal. This requires mounting orientation knowledge.
|
||||
// Assuming a standard gauge-cluster mount (portrait or landscape).
|
||||
// For now, we provide the raw components mapped to standard device axes.
|
||||
// E.g., device Y is longitudinal (forward/back), device X is lateral.
|
||||
// Forward/longitudinal acceleration (along motorcycle centerline)
|
||||
// Positive = Acceleration, Negative = Braking
|
||||
final rawLongitudinal = (event.y * math.cos(pitchRad) - event.z * math.sin(pitchRad));
|
||||
// Lateral acceleration (cornering)
|
||||
final rawLateral = (event.x * math.cos(rollRad) + event.z * math.sin(rollRad));
|
||||
|
||||
double longitudinalG = event.y / gravity;
|
||||
double lateralG = event.x / gravity;
|
||||
double rawLongitudinalG = rawLongitudinal / _gravityConst;
|
||||
double rawLateralG = rawLateral / _gravityConst;
|
||||
|
||||
// Dynamic smoothing: suppress engine high-frequency vibration while preserving vehicle dynamics
|
||||
_filteredLongitudinalG = _gAlpha * rawLongitudinalG + (1.0 - _gAlpha) * _filteredLongitudinalG;
|
||||
_filteredLateralG = _gAlpha * rawLateralG + (1.0 - _gAlpha) * _filteredLateralG;
|
||||
|
||||
// In a real scenario, these would be transformed by the calibration matrix.
|
||||
_gForceController.add(GForceData(
|
||||
longitudinal: longitudinalG,
|
||||
lateral: lateralG,
|
||||
longitudinal: _filteredLongitudinalG,
|
||||
lateral: _filteredLateralG,
|
||||
accelX: event.x,
|
||||
accelY: event.y,
|
||||
accelZ: event.z,
|
||||
|
|
@ -121,8 +168,10 @@ class SensorFusionService {
|
|||
}
|
||||
|
||||
void calibrateUpright() {
|
||||
// Zero out the current lean
|
||||
// Zero out the current lean & pitch and persist to storage
|
||||
_rollOffset = _currentRoll;
|
||||
_pitchOffset = _currentPitch;
|
||||
_calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@ import '../models/telemetry_sample.dart';
|
|||
import 'location_service.dart';
|
||||
import 'sensor_fusion_service.dart';
|
||||
import 'compass_service.dart';
|
||||
import 'field_test/field_test_logger.dart';
|
||||
|
||||
final telemetryServiceProvider = Provider<TelemetryService>((ref) {
|
||||
final locationService = ref.watch(locationServiceProvider);
|
||||
final sensorFusionService = ref.watch(sensorFusionServiceProvider);
|
||||
final compassService = ref.watch(compassServiceProvider);
|
||||
final fieldTestLogger = ref.watch(fieldTestLoggerProvider);
|
||||
|
||||
final service = TelemetryService(locationService, sensorFusionService, compassService);
|
||||
final service = TelemetryService(locationService, sensorFusionService, compassService, fieldTestLogger);
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
|
|
@ -24,6 +26,7 @@ class TelemetryService {
|
|||
final LocationService _locationService;
|
||||
final SensorFusionService _sensorFusionService;
|
||||
final CompassService _compassService;
|
||||
final FieldTestLogger _fieldTestLogger;
|
||||
|
||||
final _telemetryController = StreamController<TelemetrySample>.broadcast();
|
||||
Stream<TelemetrySample> get telemetryStream => _telemetryController.stream;
|
||||
|
|
@ -43,11 +46,35 @@ class TelemetryService {
|
|||
GForceData? _latestGForce;
|
||||
double _latestCompassHeading = 0.0;
|
||||
|
||||
TelemetryService(this._locationService, this._sensorFusionService, this._compassService) {
|
||||
TelemetryService(
|
||||
this._locationService,
|
||||
this._sensorFusionService,
|
||||
this._compassService,
|
||||
this._fieldTestLogger,
|
||||
) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_locationService.onGpsDiagnostic = (pos, accepted, reason) {
|
||||
if (_fieldTestLogger.isSessionActive) {
|
||||
final health = pos.accuracy <= 15.0 ? 'Healthy' : (pos.accuracy <= 35.0 ? 'Degraded' : 'Poor');
|
||||
_fieldTestLogger.logGpsEvent(
|
||||
latitude: pos.latitude,
|
||||
longitude: pos.longitude,
|
||||
altitude: pos.altitude,
|
||||
rawSpeed: pos.speed,
|
||||
filteredSpeed: _latestPosition?.speed ?? pos.speed,
|
||||
heading: pos.heading,
|
||||
accuracy: pos.accuracy,
|
||||
gpsHealth: health,
|
||||
accepted: accepted,
|
||||
rejectionReason: reason,
|
||||
timestamp: pos.timestamp,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_locationSub = _locationService.getFilteredPositionStream().listen((pos) {
|
||||
_latestPosition = pos;
|
||||
});
|
||||
|
|
@ -62,6 +89,19 @@ class TelemetryService {
|
|||
|
||||
_gForceSub = _sensorFusionService.gForceStream.listen((gForce) {
|
||||
_latestGForce = gForce;
|
||||
if (_fieldTestLogger.isSessionActive) {
|
||||
_fieldTestLogger.logImuDiagnostic(
|
||||
accelX: gForce.accelX,
|
||||
accelY: gForce.accelY,
|
||||
accelZ: gForce.accelZ,
|
||||
roll: _latestLean,
|
||||
pitch: _latestPitch,
|
||||
longitudinalG: gForce.accelY / 9.80665,
|
||||
lateralG: gForce.accelX / 9.80665,
|
||||
filteredLongitudinalG: gForce.longitudinal,
|
||||
filteredLateralG: gForce.lateral,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_compassSub = _compassService.headingStream.listen((heading) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.0.3"
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
|
|
@ -566,7 +566,7 @@ packages:
|
|||
source: hosted
|
||||
version: "4.9.2"
|
||||
intl:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ dependencies:
|
|||
share_plus: ^13.3.0
|
||||
http: ^1.3.0
|
||||
package_info_plus: ^10.2.1
|
||||
archive: ^4.0.2
|
||||
intl: ^0.20.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
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/database/database.dart';
|
||||
import 'package:motorcycle_dash/services/field_test/field_test_models.dart';
|
||||
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
||||
import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late AppDatabase db;
|
||||
late ProviderContainer container;
|
||||
late FieldTestLogger logger;
|
||||
late FieldTestExportService exportService;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
databaseProvider.overrideWithValue(db),
|
||||
],
|
||||
);
|
||||
logger = container.read(fieldTestLoggerProvider);
|
||||
exportService = container.read(fieldTestExportServiceProvider);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
container.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('Field Test Logger - Session Management & Lifecycle', () {
|
||||
test('startSession creates a new session and records environment info', () async {
|
||||
final bike = GarageMotorcycle(
|
||||
id: 1,
|
||||
year: 2024,
|
||||
makeId: 'ducati',
|
||||
makeName: 'Ducati',
|
||||
modelId: 'panigale_v4',
|
||||
modelName: 'Panigale V4',
|
||||
variantId: 's',
|
||||
variantName: 'S',
|
||||
nickname: 'Red Beast',
|
||||
isDefault: true,
|
||||
dateAdded: DateTime.now(),
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
|
||||
final sessionId = await logger.startSession(
|
||||
rideId: 101,
|
||||
bike: bike,
|
||||
rollOffset: 1.5,
|
||||
pitchOffset: -0.8,
|
||||
);
|
||||
|
||||
expect(sessionId, isNotNull);
|
||||
expect(logger.isSessionActive, isTrue);
|
||||
expect(logger.activeSessionId, equals(sessionId));
|
||||
|
||||
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
|
||||
expect(session.rideId, equals(101));
|
||||
expect(session.motorcycleMake, equals('Ducati'));
|
||||
expect(session.motorcycleModel, equals('Panigale V4'));
|
||||
expect(session.calibrationRollOffset, equals(1.5));
|
||||
expect(session.calibrationPitchOffset, equals(-0.8));
|
||||
expect(session.completed, isFalse);
|
||||
});
|
||||
|
||||
test('stopSession finalizes session, flushes data, and calculates health summary', () async {
|
||||
final sessionId = await logger.startSession(rideId: 102);
|
||||
expect(sessionId, isNotNull);
|
||||
|
||||
// Log some events
|
||||
logger.logEvent(
|
||||
eventType: FieldTestEventType.gpsFix,
|
||||
severity: FieldTestSeverity.debug,
|
||||
message: 'GPS fix acquired',
|
||||
);
|
||||
|
||||
logger.logGpsEvent(
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
accuracy: 5.0,
|
||||
gpsHealth: 'Healthy',
|
||||
accepted: true,
|
||||
);
|
||||
|
||||
await logger.stopSession();
|
||||
|
||||
expect(logger.isSessionActive, isFalse);
|
||||
expect(logger.activeSessionId, isNull);
|
||||
|
||||
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
|
||||
expect(session.completed, isTrue);
|
||||
expect(session.endedAt, isNotNull);
|
||||
expect(session.healthSummaryJson, isNotNull);
|
||||
});
|
||||
|
||||
test('Immediate flush occurs on critical severity events', () async {
|
||||
final sessionId = await logger.startSession(rideId: 103);
|
||||
expect(sessionId, isNotNull);
|
||||
|
||||
logger.logEvent(
|
||||
eventType: FieldTestEventType.loggerError,
|
||||
severity: FieldTestSeverity.critical,
|
||||
message: 'Critical subsystem failure occurred',
|
||||
);
|
||||
|
||||
// Critical event should immediately flush to DB without waiting 5s timer
|
||||
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId!))).get();
|
||||
expect(events.any((e) => e.severity == 'critical'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Field Test Logger - GPS Diagnostics & Outlier Rejection', () {
|
||||
test('Records accepted and rejected GPS points with explicit rejection reasons', () async {
|
||||
final sessionId = await logger.startSession(rideId: 104);
|
||||
expect(sessionId, isNotNull);
|
||||
|
||||
// Accepted fix
|
||||
logger.logGpsEvent(
|
||||
latitude: 34.0522,
|
||||
longitude: -118.2437,
|
||||
rawSpeed: 25.0,
|
||||
accuracy: 4.2,
|
||||
gpsHealth: 'Healthy',
|
||||
accepted: true,
|
||||
);
|
||||
|
||||
// Rejected fix (impossible speed)
|
||||
logger.logGpsEvent(
|
||||
latitude: 34.0530,
|
||||
longitude: -118.2440,
|
||||
rawSpeed: 120.0, // 268 MPH
|
||||
accuracy: 45.0,
|
||||
gpsHealth: 'Poor',
|
||||
accepted: false,
|
||||
rejectionReason: 'Impossible speed (268.4 MPH > 223 MPH)',
|
||||
);
|
||||
|
||||
await logger.flush();
|
||||
|
||||
final gpsRecords = await (db.select(db.fieldTestGpsRecords)..where((g) => g.sessionId.equals(sessionId!))).get();
|
||||
expect(gpsRecords.length, equals(2));
|
||||
expect(gpsRecords.where((g) => g.accepted).length, equals(1));
|
||||
expect(gpsRecords.where((g) => !g.accepted).length, equals(1));
|
||||
expect(gpsRecords.firstWhere((g) => !g.accepted).rejectionReason, contains('Impossible speed'));
|
||||
});
|
||||
|
||||
test('Tracks GPS outage duration and recovery time accurately', () async {
|
||||
await logger.startSession(rideId: 105);
|
||||
|
||||
logger.logGpsSignalLoss();
|
||||
// Simulate brief delay
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
logger.logGpsSignalRecovery();
|
||||
logger.logGpsBaselineReset();
|
||||
|
||||
await logger.flush();
|
||||
|
||||
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsLost), isTrue);
|
||||
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsRecovered), isTrue);
|
||||
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsBaselineReset), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Field Test Logger - IMU & Sensor Fusion Diagnostics', () {
|
||||
test('Detects and logs vibration spikes exceeding delta threshold', () async {
|
||||
final sessionId = await logger.startSession(rideId: 106);
|
||||
expect(sessionId, isNotNull);
|
||||
|
||||
// Normal IMU sample (delta = 0.05G)
|
||||
logger.logImuDiagnostic(
|
||||
accelX: 0.1,
|
||||
accelY: 0.5,
|
||||
accelZ: 9.8,
|
||||
longitudinalG: 0.52,
|
||||
filteredLongitudinalG: 0.50,
|
||||
);
|
||||
|
||||
// Vibration spike (delta = 1.34G > 0.8G threshold)
|
||||
logger.logImuDiagnostic(
|
||||
accelX: 0.2,
|
||||
accelY: 2.14,
|
||||
accelZ: 9.8,
|
||||
longitudinalG: 2.14,
|
||||
filteredLongitudinalG: 0.80,
|
||||
);
|
||||
|
||||
await logger.flush();
|
||||
|
||||
final imuRecords = await (db.select(db.fieldTestImuRecords)..where((i) => i.sessionId.equals(sessionId!))).get();
|
||||
expect(imuRecords.length, equals(2));
|
||||
|
||||
final spikeEvents = logger.liveLogs.where((l) => l.eventType == FieldTestEventType.imuSpikeFiltered);
|
||||
expect(spikeEvents.length, equals(1));
|
||||
expect(spikeEvents.first.message, contains('Vibration Spike Filtered'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Field Test Logger - Road Alerts & TTS', () {
|
||||
test('Logs alert reception and TTS speech results', () async {
|
||||
await logger.startSession(rideId: 107);
|
||||
|
||||
logger.logRoadAlertEvent(
|
||||
type: FieldTestEventType.roadAlertReceived,
|
||||
providerId: 'community_traffic',
|
||||
category: 'Police',
|
||||
title: 'Speed Trap Reported',
|
||||
distanceMeters: 250,
|
||||
);
|
||||
|
||||
logger.logTts(success: true, phrase: 'Police report ahead.');
|
||||
logger.logTts(success: false, phrase: 'Hazard ahead.', error: 'Audio focus lost');
|
||||
|
||||
expect(logger.liveLogs.any((l) => l.message.contains('Police')), isTrue);
|
||||
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.ttsSuccess), isTrue);
|
||||
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.ttsFailure), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Field Test Logger - Safe Retention & Active Protection', () {
|
||||
test('Active session is protected from deletion', () async {
|
||||
final sessionId = await logger.startSession(rideId: 108);
|
||||
expect(sessionId, isNotNull);
|
||||
|
||||
final deleted = await logger.deleteSession(sessionId!);
|
||||
expect(deleted, isFalse); // Must reject active session deletion
|
||||
|
||||
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
|
||||
expect(session, isNotNull);
|
||||
});
|
||||
|
||||
test('Inactive session can be deleted with cascade cleanup', () async {
|
||||
final sessionId = await logger.startSession(rideId: 109);
|
||||
expect(sessionId, isNotNull);
|
||||
|
||||
logger.logEvent(
|
||||
eventType: FieldTestEventType.speedChanged,
|
||||
severity: FieldTestSeverity.info,
|
||||
message: 'Speed 45 MPH',
|
||||
);
|
||||
await logger.stopSession();
|
||||
|
||||
final deleted = await logger.deleteSession(sessionId!);
|
||||
expect(deleted, isTrue);
|
||||
|
||||
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
|
||||
expect(session, isNull);
|
||||
|
||||
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId))).get();
|
||||
expect(events.isEmpty, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Field Test Logger - Export Service', () {
|
||||
test('Generates complete ZIP archive with README, JSON, CSVs, and diagnostics', () async {
|
||||
// 1. Create a simulated ride in DB
|
||||
final rideId = await db.into(db.rides).insert(
|
||||
RidesCompanion.insert(
|
||||
startTime: DateTime.now().subtract(const Duration(minutes: 20)),
|
||||
endTime: drift.Value(DateTime.now()),
|
||||
distanceMiles: const drift.Value(12.4),
|
||||
topSpeedMph: const drift.Value(78.5),
|
||||
averageSpeed: const drift.Value(45.2),
|
||||
maxLeanLeft: const drift.Value(34.2),
|
||||
maxLeanRight: const drift.Value(31.8),
|
||||
maxAccelG: const drift.Value(0.85),
|
||||
maxBrakingG: const drift.Value(-0.92),
|
||||
movingTime: const drift.Value(960),
|
||||
stoppedTime: const drift.Value(240),
|
||||
motorcycleMake: const drift.Value('Yamaha'),
|
||||
motorcycleModel: const drift.Value('MT-09'),
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Create telemetry samples for the ride
|
||||
await db.into(db.telemetrySamples).insert(
|
||||
TelemetrySamplesCompanion.insert(
|
||||
rideId: rideId,
|
||||
timestamp: DateTime.now(),
|
||||
latitude: const drift.Value(37.7749),
|
||||
longitude: const drift.Value(-122.4194),
|
||||
speed: const drift.Value(45.0),
|
||||
heading: const drift.Value(180.0),
|
||||
leanAngle: const drift.Value(-15.2),
|
||||
longitudinalG: const drift.Value(0.24),
|
||||
lateralG: const drift.Value(0.12),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. Start & log field test session
|
||||
final sessionId = await logger.startSession(rideId: rideId);
|
||||
expect(sessionId, isNotNull);
|
||||
|
||||
logger.logGpsEvent(
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
rawSpeed: 20.1,
|
||||
filteredSpeed: 20.1,
|
||||
gpsHealth: 'Healthy',
|
||||
accepted: true,
|
||||
);
|
||||
|
||||
logger.logImuDiagnostic(
|
||||
accelX: 0.1,
|
||||
accelY: 0.2,
|
||||
accelZ: 9.8,
|
||||
longitudinalG: 0.2,
|
||||
lateralG: 0.1,
|
||||
);
|
||||
|
||||
await logger.stopSession();
|
||||
|
||||
// 4. Test README / Report generation directly
|
||||
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
|
||||
expect(session.completed, isTrue);
|
||||
|
||||
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId!))).get();
|
||||
expect(events.isNotEmpty, isTrue);
|
||||
expect(exportService, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -5,9 +5,15 @@ import 'package:motorcycle_dash/services/map/map_providers.dart';
|
|||
import 'package:motorcycle_dash/services/map/open_street_map_provider.dart';
|
||||
import 'package:motorcycle_dash/services/map/google_map_provider.dart';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('Map Provider Architecture Tests', () {
|
||||
test('AppLatLng equality and initialization', () {
|
||||
const p1 = AppLatLng(37.7749, -122.4194);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
|
||||
import 'package:motorcycle_dash/features/performance/performance_timer_service.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
import 'dart:async';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Phase 3 & 4: Speed & Conversion Validation', () {
|
||||
test('m/s to MPH conversion factor is exact', () {
|
||||
const double speedMps = 26.8224; // 60 MPH in m/s
|
||||
final speedMph = speedMps * 2.23694;
|
||||
expect((speedMph - 60.0).abs(), lessThan(0.01));
|
||||
});
|
||||
|
||||
test('100 MPH speed conversion matches physical expectation', () {
|
||||
const double speedMps = 44.704; // 100 MPH in m/s
|
||||
final speedMph = speedMps * 2.23694;
|
||||
expect((speedMph - 100.0).abs(), lessThan(0.01));
|
||||
});
|
||||
});
|
||||
|
||||
group('Phase 8 & 9: Database & Interrupted Ride Recovery', () {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
// Use in-memory SQLite database
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('Unfinished interrupted ride is cleanly identified and recovered with non-null endTime', () async {
|
||||
// Insert an unclosed ride (simulating an app kill during a ride)
|
||||
final startTime = DateTime.now().subtract(const Duration(minutes: 45));
|
||||
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
||||
startTime: startTime,
|
||||
distanceMiles: const drift.Value(24.5),
|
||||
topSpeedMph: const drift.Value(78.0),
|
||||
movingTime: const drift.Value(2400),
|
||||
));
|
||||
|
||||
// Insert some telemetry samples for this ride
|
||||
final lastSampleTime = startTime.add(const Duration(minutes: 40));
|
||||
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
|
||||
rideId: rideId,
|
||||
timestamp: lastSampleTime,
|
||||
speed: const drift.Value(65.0),
|
||||
latitude: const drift.Value(37.7749),
|
||||
longitude: const drift.Value(-122.4194),
|
||||
));
|
||||
|
||||
// Query unclosed rides
|
||||
final unclosed = await (db.select(db.rides)..where((r) => r.endTime.isNull())).get();
|
||||
expect(unclosed.length, equals(1));
|
||||
expect(unclosed.first.id, equals(rideId));
|
||||
|
||||
// Simulate the recovery logic
|
||||
final lastSample = await (db.select(db.telemetrySamples)
|
||||
..where((t) => t.rideId.equals(unclosed.first.id))
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp, mode: drift.OrderingMode.desc)])
|
||||
..limit(1)
|
||||
).getSingleOrNull();
|
||||
|
||||
final calculatedEndTime = lastSample?.timestamp ?? unclosed.first.startTime.add(Duration(seconds: unclosed.first.movingTime));
|
||||
|
||||
await (db.update(db.rides)..where((t) => t.id.equals(unclosed.first.id))).write(
|
||||
RidesCompanion(endTime: drift.Value(calculatedEndTime)),
|
||||
);
|
||||
|
||||
// Verify ride is now cleanly closed
|
||||
final closedRide = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle();
|
||||
expect(closedRide.endTime, isNotNull);
|
||||
expect(closedRide.endTime!.difference(lastSampleTime).inSeconds.abs(), lessThanOrEqualTo(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('Phase 10: Performance Timer Interpolation', () {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('Performance Timer reaches completed state on 0-60 threshold cross', () async {
|
||||
final controller = StreamController<TelemetrySample>.broadcast();
|
||||
final timerService = PerformanceTimerService(db, controller.stream);
|
||||
|
||||
timerService.setTarget(PerformanceTarget.sixty);
|
||||
expect(timerService.state.state, equals(PerformanceState.ready));
|
||||
|
||||
final baseTime = DateTime.now();
|
||||
|
||||
// Send stationary sample
|
||||
controller.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 0.0,
|
||||
longitudinalG: 0.0,
|
||||
));
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
// Send launch sample (speed > 1.0, forward accel > 0.2G)
|
||||
controller.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 200)),
|
||||
speed: 5.0,
|
||||
longitudinalG: 0.8,
|
||||
));
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
expect(timerService.state.state, equals(PerformanceState.timing));
|
||||
|
||||
// Send intermediate speed sample
|
||||
controller.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 1500)),
|
||||
speed: 35.0,
|
||||
longitudinalG: 0.6,
|
||||
));
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
// Send crossing speed sample (62 MPH at 2800ms)
|
||||
controller.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 2800)),
|
||||
speed: 62.0,
|
||||
longitudinalG: 0.5,
|
||||
));
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(timerService.state.state, equals(PerformanceState.completed));
|
||||
expect(timerService.state.lastResult, isNotNull);
|
||||
expect(timerService.state.lastResult!.target, equals(PerformanceTarget.sixty));
|
||||
expect(timerService.state.lastResult!.timeSeconds, greaterThan(0.0));
|
||||
expect(timerService.state.lastResult!.timeSeconds, lessThan(3.0));
|
||||
|
||||
timerService.dispose();
|
||||
await controller.close();
|
||||
});
|
||||
});
|
||||
|
||||
group('Phase 24: GPX & CSV Export Validation', () {
|
||||
test('GPX output contains valid XML structure and telemetry extensions', () {
|
||||
final samples = [
|
||||
TelemetrySample(
|
||||
timestamp: DateTime.utc(2026, 8, 27, 10, 0, 0),
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
altitude: 15.0,
|
||||
speed: 45.0,
|
||||
heading: 180.0,
|
||||
leanAngle: 12.5,
|
||||
longitudinalG: 0.2,
|
||||
lateralG: 0.1,
|
||||
),
|
||||
];
|
||||
|
||||
// Verify telemetry fields for export formatting
|
||||
expect(samples.first.latitude, equals(37.7749));
|
||||
expect(samples.first.longitude, equals(-122.4194));
|
||||
expect(samples.first.speed, equals(45.0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/models/motorcycle_models.dart';
|
||||
import 'package:motorcycle_dash/services/motorcycle/local_motorcycle_data_provider.dart';
|
||||
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart';
|
||||
import 'package:motorcycle_dash/features/performance/performance_timer_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Motorcycle Specifications Verification', () {
|
||||
test('2001 Yamaha YZF-R6 returns factory-accurate engine specs', () async {
|
||||
final repository = HybridMotorcycleRepository(
|
||||
localProvider: LocalMotorcycleDataProvider(),
|
||||
);
|
||||
|
||||
final spec = await repository.getSpecifications(
|
||||
MotorcycleSelection(
|
||||
year: 2001,
|
||||
makeId: 'yamaha',
|
||||
makeName: 'Yamaha',
|
||||
modelId: 'yzf_r6',
|
||||
modelName: 'YZF-R6',
|
||||
),
|
||||
);
|
||||
|
||||
expect(spec, isNotNull);
|
||||
expect(spec!.displacementCc, equals(599));
|
||||
expect(spec.cylinderCount, equals(4));
|
||||
expect(spec.horsepower, equals(120.0));
|
||||
expect(spec.horsepowerRpm, equals(13000));
|
||||
expect(spec.torqueLbFt, equals(50.2));
|
||||
expect(spec.redlineRpm, equals(15500));
|
||||
expect(spec.wetWeightLbs, equals(414.0));
|
||||
expect(spec.claimedTopSpeedMph, equals(160.0));
|
||||
expect(spec.claimedZeroToSixtySec, equals(3.0));
|
||||
});
|
||||
|
||||
test('Single-digit model names (R1, R7, R3) match displacement and cylinder counts correctly', () async {
|
||||
final provider = LocalMotorcycleDataProvider();
|
||||
|
||||
final r1Spec = await provider.getSpecifications(
|
||||
MotorcycleSelection(year: 2024, makeId: 'yamaha', makeName: 'Yamaha', modelId: 'r1', modelName: 'YZF-R1'),
|
||||
);
|
||||
expect(r1Spec, isNotNull);
|
||||
expect(r1Spec!.displacementCc, equals(998));
|
||||
expect(r1Spec.cylinderCount, equals(4));
|
||||
|
||||
final r7Spec = await provider.getSpecifications(
|
||||
MotorcycleSelection(year: 2024, makeId: 'yamaha', makeName: 'Yamaha', modelId: 'r7', modelName: 'YZF-R7'),
|
||||
);
|
||||
expect(r7Spec, isNotNull);
|
||||
expect(r7Spec!.displacementCc, equals(689));
|
||||
expect(r7Spec.cylinderCount, equals(2));
|
||||
|
||||
final r3Spec = await provider.getSpecifications(
|
||||
MotorcycleSelection(year: 2024, makeId: 'yamaha', makeName: 'Yamaha', modelId: 'r3', modelName: 'YZF-R3'),
|
||||
);
|
||||
expect(r3Spec, isNotNull);
|
||||
expect(r3Spec!.displacementCc, equals(321));
|
||||
expect(r3Spec.cylinderCount, equals(2));
|
||||
});
|
||||
});
|
||||
|
||||
group('Performance Timer Subsystem', () {
|
||||
test('Timer targets and states exist and arm properly', () async {
|
||||
expect(PerformanceTarget.sixty, isNotNull);
|
||||
expect(PerformanceState.ready, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue