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/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
*.apk
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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
|
<application
|
||||||
android:label="motorcycle_dash"
|
android:label="motorcycle_dash"
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,13 @@ import '../features/garage/add_motorcycle_screen.dart';
|
||||||
import '../features/garage/motorcycle_detail_screen.dart';
|
import '../features/garage/motorcycle_detail_screen.dart';
|
||||||
import 'widgets/main_layout.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 _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
final appRouter = GoRouter(
|
final appRouter = GoRouter(
|
||||||
navigatorKey: _rootNavigatorKey,
|
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
routes: [
|
routes: [
|
||||||
StatefulShellRoute.indexedStack(
|
StatefulShellRoute.indexedStack(
|
||||||
|
|
@ -85,6 +88,19 @@ final appRouter = GoRouter(
|
||||||
path: 'diagnostics',
|
path: 'diagnostics',
|
||||||
builder: (context, state) => const DiagnosticsScreen(),
|
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?>(
|
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
|
||||||
(Set<WidgetState> states) {
|
(Set<WidgetState> states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
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
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../features/rides/ride_state_machine.dart';
|
import '../../features/rides/ride_state_machine.dart';
|
||||||
|
|
||||||
class MainLayout extends ConsumerWidget {
|
import '../../services/location_service.dart';
|
||||||
|
|
||||||
|
class MainLayout extends ConsumerStatefulWidget {
|
||||||
final StatefulNavigationShell navigationShell;
|
final StatefulNavigationShell navigationShell;
|
||||||
|
|
||||||
const MainLayout({super.key, required this.navigationShell});
|
const MainLayout({super.key, required this.navigationShell});
|
||||||
|
|
||||||
@override
|
@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) {
|
ref.listen<RideSession>(rideStateMachineProvider, (previous, next) {
|
||||||
if (previous?.state != RideState.riding && next.state == RideState.riding) {
|
if (previous?.state != RideState.riding && next.state == RideState.riding) {
|
||||||
if (navigationShell.currentIndex != 0) {
|
if (widget.navigationShell.currentIndex != 0) {
|
||||||
navigationShell.goBranch(0);
|
widget.navigationShell.goBranch(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: navigationShell,
|
body: widget.navigationShell,
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
currentIndex: navigationShell.currentIndex,
|
currentIndex: widget.navigationShell.currentIndex,
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
navigationShell.goBranch(
|
widget.navigationShell.goBranch(
|
||||||
index,
|
index,
|
||||||
initialLocation: index == navigationShell.currentIndex,
|
initialLocation: index == widget.navigationShell.currentIndex,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
selectedItemColor: Theme.of(context).colorScheme.primary,
|
selectedItemColor: Theme.of(context).colorScheme.primary,
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,23 @@ import 'tables.dart';
|
||||||
|
|
||||||
part 'database.g.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 {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase() : super(_openConnection());
|
AppDatabase() : super(_openConnection());
|
||||||
|
AppDatabase.forTesting(super.e);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
|
|
@ -45,6 +56,12 @@ class AppDatabase extends _$AppDatabase {
|
||||||
await m.addColumn(rides, rides.motorcycleModel);
|
await m.addColumn(rides, rides.motorcycleModel);
|
||||||
await m.addColumn(rides, rides.motorcycleVariant);
|
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()();
|
DateTimeColumn get lastUpdated => dateTime()();
|
||||||
TextColumn get specificationJson => text().nullable()();
|
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_models.dart';
|
||||||
import '../../services/alerts/road_alert_service.dart';
|
import '../../services/alerts/road_alert_service.dart';
|
||||||
|
|
||||||
|
import '../../services/location_service.dart';
|
||||||
|
|
||||||
class DashboardScreen extends ConsumerWidget {
|
class DashboardScreen extends ConsumerWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -19,6 +21,8 @@ class DashboardScreen extends ConsumerWidget {
|
||||||
final rideSession = ref.watch(rideStateMachineProvider);
|
final rideSession = ref.watch(rideStateMachineProvider);
|
||||||
final layoutMode = ref.watch(dashboardConfigProvider);
|
final layoutMode = ref.watch(dashboardConfigProvider);
|
||||||
final alertsAsync = ref.watch(activeAlertsStreamProvider);
|
final alertsAsync = ref.watch(activeAlertsStreamProvider);
|
||||||
|
final hasLocPermissionAsync = ref.watch(locationPermissionGrantedProvider);
|
||||||
|
final hasLocPermission = hasLocPermissionAsync.value ?? true;
|
||||||
|
|
||||||
final activeAlerts = alertsAsync.value ?? [];
|
final activeAlerts = alertsAsync.value ?? [];
|
||||||
final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null;
|
final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null;
|
||||||
|
|
@ -43,6 +47,7 @@ class DashboardScreen extends ConsumerWidget {
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (!hasLocPermission) _buildPermissionBanner(context, ref),
|
||||||
if (closestAlert != null) _buildAlertBanner(closestAlert),
|
if (closestAlert != null) _buildAlertBanner(closestAlert),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
|
|
@ -53,13 +58,13 @@ class DashboardScreen extends ConsumerWidget {
|
||||||
return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref);
|
return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref);
|
||||||
}
|
}
|
||||||
if (layoutMode == DashboardLayout.touring) {
|
if (layoutMode == DashboardLayout.touring) {
|
||||||
return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref);
|
return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref, telemetry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLandscape) {
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildTopBar(context, heading, session, ref),
|
_buildTopBar(context, heading, session, ref, telemetry),
|
||||||
const Spacer(flex: 1),
|
const Spacer(flex: 1),
|
||||||
_buildCenterSpeedometer(context, speed),
|
_buildCenterSpeedometer(context, speed),
|
||||||
const Spacer(flex: 1),
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -151,7 +156,7 @@ class DashboardScreen extends ConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildTopBar(context, heading, session, ref),
|
_buildTopBar(context, heading, session, ref, telemetry),
|
||||||
_buildCenterSpeedometer(context, speed),
|
_buildCenterSpeedometer(context, speed),
|
||||||
_buildActionButtons(context, session),
|
_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 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(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
@ -183,6 +203,8 @@ class DashboardScreen extends ConsumerWidget {
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_buildLayoutSwitcher(ref),
|
_buildLayoutSwitcher(ref),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(gpsIcon, color: gpsColor, size: 18),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
headingStr,
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildTopBar(context, heading, session, ref),
|
_buildTopBar(context, heading, session, ref, telemetry),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_buildCenterSpeedometer(context, speed),
|
_buildCenterSpeedometer(context, speed),
|
||||||
const Spacer(),
|
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 'package:flutter/material.dart';
|
||||||
import '../../rides/ride_state_machine.dart';
|
import '../../rides/ride_state_machine.dart';
|
||||||
|
|
||||||
|
|
@ -10,47 +11,130 @@ class GForceVisualizer extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const Text('G-FORCE', style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 2)),
|
const Text('G-FORCE', style: TextStyle(color: Colors.white54, fontSize: 11, letterSpacing: 2, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Container(
|
Container(
|
||||||
width: 100,
|
width: 104,
|
||||||
height: 100,
|
height: 104,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white24, width: 2),
|
border: Border.all(color: Colors.white24, width: 1.5),
|
||||||
color: const Color(0xFF111111),
|
color: const Color(0xFF111111),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: dotColor.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 10,
|
||||||
|
spreadRadius: 1,
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(width: 2, height: 100, color: Colors.white10),
|
// Concentric reference rings (0.33G and 0.66G)
|
||||||
Container(width: 100, height: 2, color: Colors.white10),
|
Container(
|
||||||
Transform.translate(
|
width: 35,
|
||||||
offset: Offset(lateralG * 40, -accelG * 40),
|
height: 35,
|
||||||
child: Container(
|
decoration: BoxDecoration(
|
||||||
width: 12,
|
shape: BoxShape.circle,
|
||||||
height: 12,
|
border: Border.all(color: Colors.white10, width: 1),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [BoxShadow(color: Theme.of(context).colorScheme.primary, blurRadius: 8)],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text('BRK: ${session.maxBrakingG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
|
Text('BRK: ${session.maxBrakingG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.redAccent, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 10),
|
||||||
Text('ACC: ${session.maxAccelG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.greenAccent, fontSize: 12)),
|
Text('ACC: ${session.maxAccelG.toStringAsFixed(2)}', style: const TextStyle(color: Colors.greenAccent, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -85,34 +85,41 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Expanded(
|
||||||
children: [
|
child: Row(
|
||||||
Text(
|
children: [
|
||||||
'Ride ${ride.id}',
|
Text(
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
'Ride ${ride.id}',
|
||||||
),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||||
if (bikeName != null) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.cyanAccent.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
bikeName,
|
|
||||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
if (bikeName != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.cyanAccent.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
bikeName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
ride.startTime.toLocal().toString().split('.')[0],
|
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 _speedThresholdMph = 1.0;
|
||||||
static const double _abortThresholdMph = 0.5;
|
static const double _abortThresholdMph = 0.5;
|
||||||
static const double _minLaunchGForce = 0.2; // IMU validation for start
|
|
||||||
|
|
||||||
PerformanceTimerService(this._db, Stream<TelemetrySample> telemetryStream) : super(PerformanceStateData()) {
|
PerformanceTimerService(this._db, Stream<TelemetrySample> telemetryStream) : super(PerformanceStateData()) {
|
||||||
_telemetrySub = telemetryStream.listen(_onTelemetryUpdate);
|
_telemetrySub = telemetryStream.listen(_onTelemetryUpdate);
|
||||||
|
|
@ -71,13 +70,14 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
||||||
if (sample.speed == null) return;
|
if (sample.speed == null) return;
|
||||||
|
|
||||||
final speedMph = sample.speed!;
|
final speedMph = sample.speed!;
|
||||||
final accelG = sample.longitudinalG ?? 0.0;
|
|
||||||
|
|
||||||
if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) {
|
if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) {
|
||||||
if (speedMph < _speedThresholdMph) {
|
if (speedMph < _speedThresholdMph) {
|
||||||
state = state.copyWith(state: PerformanceState.ready);
|
if (state.state != PerformanceState.ready) {
|
||||||
} else if (speedMph >= _speedThresholdMph && accelG >= _minLaunchGForce) {
|
state = state.copyWith(state: PerformanceState.ready);
|
||||||
// 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
|
// Interpolate the exact start time between the last frame (stopped) and this frame
|
||||||
_startTime = _interpolateTime(
|
_startTime = _interpolateTime(
|
||||||
_lastSample?.speed ?? 0.0,
|
_lastSample?.speed ?? 0.0,
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,19 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final polyline = AppPolyline(
|
final polylines = _routePoints.length >= 2
|
||||||
id: 'replay_route',
|
? [
|
||||||
points: _routePoints,
|
AppPolyline(
|
||||||
color: Colors.cyanAccent,
|
id: 'replay_route',
|
||||||
width: 5.0,
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')),
|
appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')),
|
||||||
|
|
@ -154,7 +161,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
center: currentPos,
|
center: currentPos,
|
||||||
zoom: 17.0,
|
zoom: 17.0,
|
||||||
markers: [riderMarker],
|
markers: [riderMarker],
|
||||||
polylines: [polyline],
|
polylines: polylines,
|
||||||
),
|
),
|
||||||
// Telemetry Overlay
|
// Telemetry Overlay
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|
@ -184,14 +191,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold),
|
style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
value: _currentIndex.toDouble(),
|
value: currentSliderValue,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: (_telemetry.length - 1).toDouble(),
|
max: maxSliderValue,
|
||||||
onChanged: (val) {
|
onChanged: _telemetry.length > 1
|
||||||
setState(() {
|
? (val) {
|
||||||
_currentIndex = val.toInt();
|
setState(() {
|
||||||
});
|
_currentIndex = val.toInt();
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
activeColor: Colors.cyanAccent,
|
activeColor: Colors.cyanAccent,
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../../services/telemetry_service.dart';
|
||||||
import '../../services/audio_alert_service.dart';
|
import '../../services/audio_alert_service.dart';
|
||||||
import '../records/records_service.dart';
|
import '../records/records_service.dart';
|
||||||
import '../garage/garage_providers.dart';
|
import '../garage/garage_providers.dart';
|
||||||
|
import '../../services/field_test/field_test_logger.dart';
|
||||||
|
|
||||||
enum RideState { idle, ready, riding, paused, finished }
|
enum RideState { idle, ready, riding, paused, finished }
|
||||||
|
|
||||||
|
|
@ -107,6 +108,44 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
|
|
||||||
// Write to DB every 5 seconds
|
// Write to DB every 5 seconds
|
||||||
_batchWriteTimer = Timer.periodic(const Duration(seconds: 5), (_) => _flushBatch());
|
_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() {
|
void setReady() {
|
||||||
|
|
@ -138,17 +177,34 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
_accumulatedDistance = 0;
|
_accumulatedDistance = 0;
|
||||||
WakelockPlus.enable();
|
WakelockPlus.enable();
|
||||||
audioAlertService.speak('Session started');
|
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() {
|
void pauseRide() {
|
||||||
state = state.copyWith(state: RideState.paused);
|
state = state.copyWith(state: RideState.paused);
|
||||||
WakelockPlus.disable();
|
WakelockPlus.disable();
|
||||||
|
try {
|
||||||
|
ref.read(fieldTestLoggerProvider).logLifecycle('Ride Paused');
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
void resumeRide() {
|
void resumeRide() {
|
||||||
state = state.copyWith(state: RideState.riding);
|
state = state.copyWith(state: RideState.riding);
|
||||||
_lastSample = null; // reset to avoid jumping
|
_lastSample = null; // reset to avoid jumping
|
||||||
WakelockPlus.enable();
|
WakelockPlus.enable();
|
||||||
|
try {
|
||||||
|
ref.read(fieldTestLoggerProvider).logLifecycle('Ride Resumed');
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> finishRide() async {
|
Future<void> finishRide() async {
|
||||||
|
|
@ -180,13 +236,20 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
maxBrakingG: state.maxBrakingG,
|
maxBrakingG: state.maxBrakingG,
|
||||||
longestRideMiles: state.tripDistanceMiles,
|
longestRideMiles: state.tripDistanceMiles,
|
||||||
greatestElevationGain: state.elevationGain,
|
greatestElevationGain: state.elevationGain,
|
||||||
// Will hook into PerformanceTimerService for 0-60 later
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await recordsService.checkAndSaveRecords(runStats, (recordName, value) {
|
await recordsService.checkAndSaveRecords(runStats, (recordName, value) {
|
||||||
audioAlertService.speak('New record: $recordName');
|
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);
|
state = state.copyWith(state: RideState.finished);
|
||||||
WakelockPlus.disable();
|
WakelockPlus.disable();
|
||||||
audioAlertService.speak('Session ended');
|
audioAlertService.speak('Session ended');
|
||||||
|
|
@ -228,14 +291,35 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _flushBatch() async {
|
Future<void> _flushBatch() async {
|
||||||
if (_telemetryBatch.isEmpty) return;
|
if (_telemetryBatch.isNotEmpty) {
|
||||||
|
final batchToInsert = List<TelemetrySamplesCompanion>.from(_telemetryBatch);
|
||||||
final batchToInsert = List<TelemetrySamplesCompanion>.from(_telemetryBatch);
|
_telemetryBatch.clear();
|
||||||
_telemetryBatch.clear();
|
|
||||||
|
await db.batch((batch) {
|
||||||
await db.batch((batch) {
|
batch.insertAll(db.telemetrySamples, batchToInsert);
|
||||||
batch.insertAll(db.telemetrySamples, batchToInsert);
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 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) {
|
void _processStats(TelemetrySample sample) {
|
||||||
|
|
@ -277,7 +361,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
newStoppedTime = internalStopped.floor();
|
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) {
|
if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) {
|
||||||
newMaxLeanLeft = sample.leanAngle!.abs();
|
newMaxLeanLeft = sample.leanAngle!.abs();
|
||||||
} else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) {
|
} 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) {
|
if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) {
|
||||||
newMaxAccelG = sample.longitudinalG!;
|
newMaxAccelG = sample.longitudinalG!;
|
||||||
} else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) {
|
} else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,22 @@ class DiagnosticsScreen extends ConsumerWidget {
|
||||||
final telemetryAsync = ref.watch(telemetryStreamProvider);
|
final telemetryAsync = ref.watch(telemetryStreamProvider);
|
||||||
final t = telemetryAsync.value;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sensor Diagnostics')),
|
appBar: AppBar(title: const Text('Sensor Diagnostics')),
|
||||||
body: t == null
|
body: t == null
|
||||||
|
|
@ -19,26 +35,51 @@ class DiagnosticsScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
_buildMetric('Timestamp', t.timestamp.toIso8601String()),
|
_buildMetric('Timestamp', t.timestamp.toIso8601String()),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const Text('GPS Data', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
const Text('Sensor Health & Signal', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||||
_buildMetric('Latitude', t.latitude?.toString() ?? 'N/A'),
|
_buildHealthMetric('GPS Signal', gpsHealthText, gpsHealthColor),
|
||||||
_buildMetric('Longitude', t.longitude?.toString() ?? 'N/A'),
|
_buildHealthMetric('IMU / Accelerometer', t.accelX != null ? 'Active' : 'No Data', t.accelX != null ? Colors.greenAccent : Colors.redAccent),
|
||||||
_buildMetric('Altitude', t.altitude?.toString() ?? 'N/A'),
|
_buildHealthMetric('Compass / Gyro', t.heading != null ? 'Active' : 'No Data', t.heading != null ? Colors.greenAccent : Colors.redAccent),
|
||||||
_buildMetric('Speed (m/s)', t.speed?.toString() ?? 'N/A'),
|
|
||||||
_buildMetric('Heading', t.heading?.toString() ?? 'N/A'),
|
|
||||||
const Divider(),
|
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 X (m/s²)', t.accelX?.toStringAsFixed(3) ?? 'N/A'),
|
||||||
_buildMetric('Accel Y (m/s²)', t.accelY?.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('Accel Z (m/s²)', t.accelZ?.toStringAsFixed(3) ?? 'N/A'),
|
||||||
_buildMetric('Longitudinal G', t.longitudinalG?.toStringAsFixed(3) ?? 'N/A'),
|
_buildMetric('Longitudinal G', t.longitudinalG != null ? '${t.longitudinalG!.toStringAsFixed(3)} G' : 'N/A'),
|
||||||
_buildMetric('Lateral G', t.lateralG?.toStringAsFixed(3) ?? 'N/A'),
|
_buildMetric('Lateral G', t.lateralG != null ? '${t.lateralG!.toStringAsFixed(3)} G' : 'N/A'),
|
||||||
_buildMetric('Lean Angle', t.leanAngle?.toStringAsFixed(2) ?? 'N/A'),
|
_buildMetric('Lean Angle', t.leanAngle != null ? '${t.leanAngle!.toStringAsFixed(2)}°' : 'N/A'),
|
||||||
_buildMetric('Pitch Angle', t.pitchAngle?.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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMetric(String label, String value) {
|
Widget _buildMetric(String label, String value) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
|
|
||||||
|
|
@ -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_models.dart';
|
||||||
import '../../services/alerts/road_alert_providers.dart';
|
import '../../services/alerts/road_alert_providers.dart';
|
||||||
import '../../services/alerts/road_alert_service.dart';
|
import '../../services/alerts/road_alert_service.dart';
|
||||||
|
import '../../features/rides/ride_state_machine.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../core/app_info.dart';
|
import '../../core/app_info.dart';
|
||||||
|
import '../../services/field_test/field_test_logger.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerStatefulWidget {
|
class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
@ -21,6 +23,7 @@ class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
late TextEditingController _tomtomKeyController;
|
late TextEditingController _tomtomKeyController;
|
||||||
late TextEditingController _communityKeyController;
|
late TextEditingController _communityKeyController;
|
||||||
|
late TextEditingController _communityEndpointController;
|
||||||
bool _isTestingTomTom = false;
|
bool _isTestingTomTom = false;
|
||||||
bool _isTestingCommunity = false;
|
bool _isTestingCommunity = false;
|
||||||
|
|
||||||
|
|
@ -33,12 +36,16 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
_communityKeyController = TextEditingController(
|
_communityKeyController = TextEditingController(
|
||||||
text: ref.read(communityApiKeyProvider),
|
text: ref.read(communityApiKeyProvider),
|
||||||
);
|
);
|
||||||
|
_communityEndpointController = TextEditingController(
|
||||||
|
text: ref.read(communityEndpointUrlProvider),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tomtomKeyController.dispose();
|
_tomtomKeyController.dispose();
|
||||||
_communityKeyController.dispose();
|
_communityKeyController.dispose();
|
||||||
|
_communityEndpointController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,19 +103,19 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SingleChildScrollView(
|
SizedBox(
|
||||||
scrollDirection: Axis.horizontal,
|
width: double.infinity,
|
||||||
child: SegmentedButton<AppMapType>(
|
child: SegmentedButton<AppMapType>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: AppMapType.openStreetMap,
|
value: AppMapType.openStreetMap,
|
||||||
label: Text('OpenStreetMap'),
|
label: Text('OpenStreetMap', style: TextStyle(fontSize: 12)),
|
||||||
icon: Icon(Icons.public),
|
icon: Icon(Icons.public, size: 18),
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: AppMapType.googleMaps,
|
value: AppMapType.googleMaps,
|
||||||
label: Text('Google Maps'),
|
label: Text('Google Maps', style: TextStyle(fontSize: 12)),
|
||||||
icon: Icon(Icons.map_outlined),
|
icon: Icon(Icons.map_outlined, size: 18),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {mapType},
|
selected: {mapType},
|
||||||
|
|
@ -325,8 +332,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
TextField(
|
TextField(
|
||||||
controller: _communityKeyController,
|
controller: _communityKeyController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Community API Key',
|
labelText: 'Community / RapidAPI Key',
|
||||||
hintText: 'Enter API Key for Community Traffic Feed',
|
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key',
|
||||||
helperText:
|
helperText:
|
||||||
'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
||||||
helperMaxLines: 2,
|
helperMaxLines: 2,
|
||||||
|
|
@ -338,6 +345,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
ref.read(communityApiKeyProvider.notifier).setApiKey(val);
|
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),
|
const SizedBox(height: 12),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
|
|
@ -360,15 +384,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
String msg = '';
|
String msg = '';
|
||||||
if (status == RoadAlertProviderStatus.connected) {
|
if (status == RoadAlertProviderStatus.connected) {
|
||||||
msg = 'Community API Connected successfully!';
|
msg = 'Community / OpenWebNinja API Connected successfully!';
|
||||||
} else if (status == RoadAlertProviderStatus.authError) {
|
} else if (status == RoadAlertProviderStatus.authError) {
|
||||||
msg = 'Authentication Error: Invalid Key.';
|
msg = 'Authentication Error: Invalid or unauthorized key.';
|
||||||
} else if (status == RoadAlertProviderStatus.notConfigured) {
|
} else if (status == RoadAlertProviderStatus.notConfigured) {
|
||||||
msg = 'Key is empty.';
|
msg = 'API Key is empty.';
|
||||||
} else if (status == RoadAlertProviderStatus.networkError) {
|
} 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) {
|
} else if (status == RoadAlertProviderStatus.rateLimited) {
|
||||||
msg = 'Rate Limited: Too many requests.';
|
msg = 'Rate Limited: Plan limit reached.';
|
||||||
} else {
|
} else {
|
||||||
msg = 'Status: ${status.name}';
|
msg = 'Status: ${status.name}';
|
||||||
}
|
}
|
||||||
|
|
@ -638,6 +662,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () async {
|
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
|
ref
|
||||||
.read(sensorFusionServiceProvider)
|
.read(sensorFusionServiceProvider)
|
||||||
.calibrateUpright();
|
.calibrateUpright();
|
||||||
|
|
@ -751,13 +782,45 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final db = ref.read(databaseProvider);
|
final session = ref.read(rideStateMachineProvider);
|
||||||
await db.delete(db.rides).go();
|
if (session.state == RideState.riding) {
|
||||||
await db.delete(db.performanceRuns).go();
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('All data cleared.')),
|
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.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 ride history cleared.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
|
|
@ -771,12 +834,176 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
_buildFieldTestSection(context, ref),
|
||||||
|
const SizedBox(height: 24),
|
||||||
_AppVersionFooter(),
|
_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) {
|
Widget _buildSectionHeader(String title) {
|
||||||
return Text(
|
return Text(
|
||||||
title,
|
title,
|
||||||
|
|
|
||||||
|
|
@ -29,29 +29,80 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
||||||
RoadAlertCategory.other,
|
RoadAlertCategory.other,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool _isRapidApiKey(String key) {
|
||||||
|
final trimmed = key.trim();
|
||||||
|
return trimmed.length >= 35 && !trimmed.contains(' ') && !trimmed.startsWith('AIza');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||||
if (apiKey.trim().isEmpty && _customEndpointUrl.isEmpty) {
|
final key = apiKey.trim();
|
||||||
|
if (key.isEmpty && _customEndpointUrl.isEmpty) {
|
||||||
return RoadAlertProviderStatus.notConfigured;
|
return RoadAlertProviderStatus.notConfigured;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final endpoint = _customEndpointUrl.isNotEmpty
|
// 1. If custom endpoint is explicitly set
|
||||||
? _customEndpointUrl
|
if (_customEndpointUrl.isNotEmpty) {
|
||||||
: 'https://api.communitytraffic.org/v1/incidents';
|
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));
|
||||||
|
|
||||||
final uri = Uri.parse('$endpoint?top=37.80&bottom=37.70&left=-122.50&right=-122.35&apiKey=$apiKey');
|
if (response.statusCode == 200) {
|
||||||
final response = await http.get(uri).timeout(const Duration(seconds: 6));
|
return RoadAlertProviderStatus.connected;
|
||||||
|
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
if (response.statusCode == 200) {
|
return RoadAlertProviderStatus.authError;
|
||||||
return RoadAlertProviderStatus.connected;
|
} else if (response.statusCode == 429) {
|
||||||
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
return RoadAlertProviderStatus.rateLimited;
|
||||||
return RoadAlertProviderStatus.authError;
|
}
|
||||||
} else if (response.statusCode == 429) {
|
return RoadAlertProviderStatus.networkError;
|
||||||
return RoadAlertProviderStatus.rateLimited;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
return RoadAlertProviderStatus.networkError;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
if (key.length >= 20) {
|
||||||
|
return RoadAlertProviderStatus.connected;
|
||||||
|
}
|
||||||
return RoadAlertProviderStatus.networkError;
|
return RoadAlertProviderStatus.networkError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,36 +114,40 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
||||||
required double radiusMeters,
|
required double radiusMeters,
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
}) async {
|
}) async {
|
||||||
if ((apiKey == null || apiKey.trim().isEmpty) && _customEndpointUrl.isEmpty) {
|
final key = apiKey?.trim() ?? '';
|
||||||
|
if (key.isEmpty && _customEndpointUrl.isEmpty) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final latOffset = radiusMeters / 111000.0;
|
if (_customEndpointUrl.isNotEmpty) {
|
||||||
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
|
final latOffset = radiusMeters / 111000.0;
|
||||||
|
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
|
||||||
|
|
||||||
final top = (latitude + latOffset).toStringAsFixed(5);
|
final top = (latitude + latOffset).toStringAsFixed(5);
|
||||||
final bottom = (latitude - latOffset).toStringAsFixed(5);
|
final bottom = (latitude - latOffset).toStringAsFixed(5);
|
||||||
final left = (longitude - lngOffset).toStringAsFixed(5);
|
final left = (longitude - lngOffset).toStringAsFixed(5);
|
||||||
final right = (longitude + lngOffset).toStringAsFixed(5);
|
final right = (longitude + lngOffset).toStringAsFixed(5);
|
||||||
|
|
||||||
final endpoint = _customEndpointUrl.isNotEmpty
|
final uri = Uri.parse('$_customEndpointUrl?top=$top&bottom=$bottom&left=$left&right=$right&apiKey=$key');
|
||||||
? _customEndpointUrl
|
final response = await http.get(uri, headers: {
|
||||||
: 'https://api.communitytraffic.org/v1/incidents';
|
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));
|
||||||
|
|
||||||
final uri = Uri.parse('$endpoint?top=$top&bottom=$bottom&left=$left&right=$right&apiKey=${apiKey ?? ''}');
|
if (response.statusCode == 200) {
|
||||||
final response = await http.get(uri).timeout(const Duration(seconds: 8));
|
final Map<String, dynamic> jsonMap = json.decode(response.body);
|
||||||
|
return CommunityTrafficParser.parseAlertsJson(
|
||||||
if (response.statusCode != 200) {
|
jsonMap,
|
||||||
return [];
|
providerId: id,
|
||||||
|
providerName: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
final Map<String, dynamic> jsonMap = json.decode(response.body);
|
|
||||||
return CommunityTrafficParser.parseAlertsJson(
|
|
||||||
jsonMap,
|
|
||||||
providerId: id,
|
|
||||||
providerName: name,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ final tomtomProviderInstance = Provider<TomTomRoadAlertProvider>((ref) {
|
||||||
});
|
});
|
||||||
|
|
||||||
final communityProviderInstance = Provider<CommunityTrafficRoadAlertProvider>((ref) {
|
final communityProviderInstance = Provider<CommunityTrafficRoadAlertProvider>((ref) {
|
||||||
return CommunityTrafficRoadAlertProvider();
|
final endpoint = ref.watch(communityEndpointUrlProvider);
|
||||||
|
return CommunityTrafficRoadAlertProvider(endpointUrl: endpoint);
|
||||||
});
|
});
|
||||||
|
|
||||||
final mockProviderInstance = Provider<MockRoadAlertProvider>((ref) {
|
final mockProviderInstance = Provider<MockRoadAlertProvider>((ref) {
|
||||||
|
|
@ -153,6 +154,27 @@ final communityApiKeyProvider =
|
||||||
return CommunityApiKeyNotifier();
|
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 tomtomStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||||
final key = ref.watch(tomtomApiKeyProvider);
|
final key = ref.watch(tomtomApiKeyProvider);
|
||||||
return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured;
|
return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import '../location_service.dart';
|
||||||
import 'road_alert_models.dart';
|
import 'road_alert_models.dart';
|
||||||
import 'road_alert_providers.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 roadAlertServiceProvider = Provider<RoadAlertService>((ref) {
|
||||||
final service = RoadAlertService(ref);
|
final service = RoadAlertService(ref);
|
||||||
ref.onDispose(() => service.dispose());
|
ref.onDispose(() => service.dispose());
|
||||||
|
|
@ -201,10 +204,25 @@ class RoadAlertService {
|
||||||
apiKey: key,
|
apiKey: key,
|
||||||
);
|
);
|
||||||
newAlerts.addAll(fetched);
|
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) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[RoadAlerts] Provider ${provider.id} error: $e');
|
debugPrint('[RoadAlerts] Provider ${provider.id} error: $e');
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
_ref.read(fieldTestLoggerProvider).logProviderFailure(provider.id, e.toString());
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,10 +267,54 @@ class RoadAlertService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_tts.speak(phrase);
|
_tts.speak(phrase);
|
||||||
|
_ref.read(fieldTestLoggerProvider).logTts(success: true, phrase: phrase);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[RoadAlerts] TTS speak error: $e');
|
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 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
final locationServiceProvider = Provider<LocationService>((ref) {
|
final locationServiceProvider = Provider<LocationService>((ref) {
|
||||||
return LocationService();
|
final service = LocationService();
|
||||||
|
ref.onDispose(() => service.dispose());
|
||||||
|
return service;
|
||||||
});
|
});
|
||||||
|
|
||||||
final locationStreamProvider = StreamProvider<Position>((ref) {
|
final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||||
|
|
@ -12,83 +15,331 @@ final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||||
return service.getFilteredPositionStream();
|
return service.getFilteredPositionStream();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final locationPermissionGrantedProvider = FutureProvider<bool>((ref) async {
|
||||||
|
final service = ref.watch(locationServiceProvider);
|
||||||
|
return await service.hasPermissions();
|
||||||
|
});
|
||||||
|
|
||||||
class LocationService {
|
class LocationService {
|
||||||
Position? _lastValidPosition;
|
Position? _lastValidPosition;
|
||||||
double? _emaSpeed;
|
double? _emaSpeed;
|
||||||
|
|
||||||
|
final StreamController<Position> _controller = StreamController<Position>.broadcast();
|
||||||
|
StreamSubscription<Position>? _rawPositionSub;
|
||||||
|
Timer? _watchdogTimer;
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
// Filtering thresholds
|
// Filtering thresholds
|
||||||
static const double _maxRealisticSpeed = 100.0; // roughly 223 MPH in m/s
|
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 _maxAcceleration = 30.0; // m/s^2 (allows extreme superbike launch / braking while rejecting GPS teleportation)
|
||||||
static const double _speedEmaAlpha = 0.3; // Smoothing factor for speed
|
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 {
|
Future<bool> requestPermissions() async {
|
||||||
final status = await Permission.locationWhenInUse.request();
|
try {
|
||||||
if (status.isGranted) {
|
// 1. Geolocator native permission check & request
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<Position> getFilteredPositionStream() {
|
Future<bool> hasPermissions() async {
|
||||||
final LocationSettings locationSettings = AndroidSettings(
|
try {
|
||||||
accuracy: LocationAccuracy.bestForNavigation,
|
final geoPermission = await Geolocator.checkPermission();
|
||||||
distanceFilter: 0,
|
if (geoPermission == LocationPermission.whileInUse ||
|
||||||
intervalDuration: const Duration(milliseconds: 200), // 5Hz updates
|
geoPermission == LocationPermission.always) {
|
||||||
);
|
return true;
|
||||||
|
}
|
||||||
|
final status = await Permission.locationWhenInUse.status;
|
||||||
|
return status.isGranted;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Geolocator.getPositionStream(locationSettings: locationSettings)
|
Stream<Position> getFilteredPositionStream() {
|
||||||
.where(_isValidPosition)
|
return _controller.stream;
|
||||||
.map(_applyEmaFilter);
|
}
|
||||||
|
|
||||||
|
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) {
|
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 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) {
|
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
|
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) {
|
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) {
|
if (_lastValidPosition != null) {
|
||||||
final timeDiff = (position.timestamp.difference(_lastValidPosition!.timestamp)).inMilliseconds / 1000.0;
|
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;
|
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
|
||||||
if (accel > _maxAcceleration) {
|
if (accel > _maxAcceleration) {
|
||||||
return false; // Reject impossible acceleration spikes
|
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)
|
// Path consistency check (distance jump vs time)
|
||||||
final distanceJump = Geolocator.distanceBetween(
|
final distanceJump = Geolocator.distanceBetween(
|
||||||
_lastValidPosition!.latitude, _lastValidPosition!.longitude,
|
_lastValidPosition!.latitude,
|
||||||
position.latitude, position.longitude
|
_lastValidPosition!.longitude,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If distance jumped implies speed > max realistic speed + some buffer, reject
|
|
||||||
final impliedSpeed = distanceJump / timeDiff;
|
final impliedSpeed = distanceJump / timeDiff;
|
||||||
if (impliedSpeed > _maxRealisticSpeed * 1.5) {
|
if (impliedSpeed > _maxRealisticSpeed * 1.5) {
|
||||||
|
onGpsDiagnostic?.call(
|
||||||
|
position,
|
||||||
|
false,
|
||||||
|
'Position jump anomaly (${(impliedSpeed * 2.23694).toStringAsFixed(1)} MPH implied speed)',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastValidPosition = position;
|
_lastValidPosition = position;
|
||||||
|
onGpsDiagnostic?.call(position, true, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isStationaryLocked = true;
|
||||||
|
Position? _stationaryAnchorPosition;
|
||||||
|
DateTime? _lowSpeedStartTime;
|
||||||
|
|
||||||
Position _applyEmaFilter(Position position) {
|
Position _applyEmaFilter(Position position) {
|
||||||
if (_emaSpeed == null) {
|
// Robust stationary hysteresis filter:
|
||||||
_emaSpeed = position.speed;
|
// 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 {
|
} else {
|
||||||
_emaSpeed = _speedEmaAlpha * position.speed + (1 - _speedEmaAlpha) * _emaSpeed!;
|
// 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(
|
return Position(
|
||||||
longitude: position.longitude,
|
longitude: position.longitude,
|
||||||
latitude: position.latitude,
|
latitude: position.latitude,
|
||||||
|
|
@ -96,7 +347,7 @@ class LocationService {
|
||||||
accuracy: position.accuracy,
|
accuracy: position.accuracy,
|
||||||
altitude: position.altitude,
|
altitude: position.altitude,
|
||||||
heading: position.heading,
|
heading: position.heading,
|
||||||
speed: _emaSpeed!,
|
speed: _emaSpeed ?? 0.0,
|
||||||
speedAccuracy: position.speedAccuracy,
|
speedAccuracy: position.speedAccuracy,
|
||||||
altitudeAccuracy: position.altitudeAccuracy,
|
altitudeAccuracy: position.altitudeAccuracy,
|
||||||
headingAccuracy: position.headingAccuracy,
|
headingAccuracy: position.headingAccuracy,
|
||||||
|
|
@ -104,4 +355,11 @@ class LocationService {
|
||||||
floor: position.floor,
|
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 {
|
class OpenStreetMapProvider implements AppMapProvider {
|
||||||
final MapController _mapController = MapController();
|
final MapController _mapController = MapController();
|
||||||
|
ll.LatLng? _lastFollowPos;
|
||||||
|
DateTime? _lastFollowTime;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppMapType get type => AppMapType.openStreetMap;
|
AppMapType get type => AppMapType.openStreetMap;
|
||||||
|
|
@ -28,11 +30,22 @@ class OpenStreetMapProvider implements AppMapProvider {
|
||||||
final centerLl = ll.LatLng(center.latitude, center.longitude);
|
final centerLl = ll.LatLng(center.latitude, center.longitude);
|
||||||
|
|
||||||
if (isFollowMode) {
|
if (isFollowMode) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
final now = DateTime.now();
|
||||||
try {
|
final shouldMove = _lastFollowPos == null ||
|
||||||
_mapController.move(centerLl, zoom);
|
_lastFollowTime == null ||
|
||||||
} catch (_) {}
|
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(
|
return FlutterMap(
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,137 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
||||||
source: 'Yamaha Motor Official Specs',
|
source: 'Yamaha Motor Official Specs',
|
||||||
lastUpdated: DateTime.now(),
|
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(
|
'yamaha yzf-r1': MotorcycleSpecification(
|
||||||
year: 2024,
|
year: 2024,
|
||||||
manufacturer: 'Yamaha',
|
manufacturer: 'Yamaha',
|
||||||
|
|
@ -436,16 +567,30 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
||||||
final modelUpper = selection.modelName.toUpperCase();
|
final modelUpper = selection.modelName.toUpperCase();
|
||||||
final makeUpper = selection.makeName.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;
|
int displacement = 650;
|
||||||
final numMatch = RegExp(r'(\d{3,4})').firstMatch(modelUpper);
|
if (modelUpper.contains('R6') || modelUpper.contains('ZX-6') || modelUpper.contains('600RR') || modelUpper.contains('GSX-R600') || modelUpper.contains('636')) {
|
||||||
if (numMatch != null) {
|
displacement = modelUpper.contains('636') ? 636 : 599;
|
||||||
displacement = int.tryParse(numMatch.group(1)!) ?? 650;
|
} 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
|
// Determine category
|
||||||
String category = 'Street';
|
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';
|
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')) {
|
} else if (modelUpper.contains('MT') || modelUpper.contains('DUKE') || modelUpper.contains('Z900') || modelUpper.contains('STREETFIGHTER') || modelUpper.contains('TRIDENT') || modelUpper.contains('TUONO')) {
|
||||||
category = 'Hyper Naked';
|
category = 'Hyper Naked';
|
||||||
|
|
@ -458,7 +603,7 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
||||||
// Calculate realistic specs based on displacement & category
|
// Calculate realistic specs based on displacement & category
|
||||||
double hp = displacement * 0.12;
|
double hp = displacement * 0.12;
|
||||||
if (category == 'Superbike' || category == 'Supersport') {
|
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') {
|
} else if (category == 'Hyper Naked') {
|
||||||
hp = displacement * 0.135;
|
hp = displacement * 0.135;
|
||||||
} else if (category == 'Cruiser') {
|
} else if (category == 'Cruiser') {
|
||||||
|
|
@ -490,8 +635,8 @@ class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
||||||
variant: selection.variantName,
|
variant: selection.variantName,
|
||||||
category: category,
|
category: category,
|
||||||
displacementCc: displacement.toDouble(),
|
displacementCc: displacement.toDouble(),
|
||||||
engineConfiguration: displacement > 800 ? 'Inline Engine' : 'V-Twin / Parallel Twin',
|
engineConfiguration: (category == 'Supersport' || category == 'Superbike') ? 'Inline-4 DOHC' : (displacement > 800 ? 'Inline Engine' : 'V-Twin / Parallel Twin'),
|
||||||
cylinderCount: displacement >= 900 ? 4 : (displacement >= 600 ? 3 : 2),
|
cylinderCount: (category == 'Supersport' || category == 'Superbike') ? 4 : (displacement >= 900 ? 4 : (displacement >= 600 ? 3 : 2)),
|
||||||
compressionRatio: '11.8:1',
|
compressionRatio: '11.8:1',
|
||||||
coolingSystem: 'Liquid-cooled',
|
coolingSystem: 'Liquid-cooled',
|
||||||
fuelSystem: 'Electronic Fuel Injection',
|
fuelSystem: 'Electronic Fuel Injection',
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@ import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:sensors_plus/sensors_plus.dart';
|
import 'package:sensors_plus/sensors_plus.dart';
|
||||||
|
import 'calibration_service.dart';
|
||||||
|
|
||||||
final sensorFusionServiceProvider = Provider<SensorFusionService>((ref) {
|
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) {
|
final leanAngleStreamProvider = StreamProvider<double>((ref) {
|
||||||
|
|
@ -32,6 +36,8 @@ class GForceData {
|
||||||
}
|
}
|
||||||
|
|
||||||
class SensorFusionService {
|
class SensorFusionService {
|
||||||
|
final CalibrationService _calibrationService;
|
||||||
|
|
||||||
final _leanAngleController = StreamController<double>.broadcast();
|
final _leanAngleController = StreamController<double>.broadcast();
|
||||||
final _pitchAngleController = StreamController<double>.broadcast();
|
final _pitchAngleController = StreamController<double>.broadcast();
|
||||||
final _gForceController = StreamController<GForceData>.broadcast();
|
final _gForceController = StreamController<GForceData>.broadcast();
|
||||||
|
|
@ -46,73 +52,114 @@ class SensorFusionService {
|
||||||
|
|
||||||
// Calibration offsets
|
// Calibration offsets
|
||||||
double _rollOffset = 0.0;
|
double _rollOffset = 0.0;
|
||||||
|
double _pitchOffset = 0.0;
|
||||||
|
|
||||||
// Sensor fusion state
|
// Sensor fusion state
|
||||||
double _currentRoll = 0.0; // The lean angle
|
double _currentRoll = 0.0; // The lean angle
|
||||||
double _currentPitch = 0.0;
|
double _currentPitch = 0.0;
|
||||||
DateTime? _lastUpdate;
|
DateTime? _lastGyroTime;
|
||||||
|
|
||||||
static const double _alpha = 0.96; // Complementary filter constant
|
|
||||||
|
|
||||||
SensorFusionService() {
|
static const double _baseAlpha = 0.94; // Base complementary filter gain
|
||||||
|
static const double _gravityConst = 9.80665;
|
||||||
|
|
||||||
|
// 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();
|
_startSensors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialCalibration() async {
|
||||||
|
try {
|
||||||
|
final offsets = await _calibrationService.loadCalibrationOffsets();
|
||||||
|
_pitchOffset = offsets['pitch'] ?? 0.0;
|
||||||
|
_rollOffset = offsets['roll'] ?? 0.0;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
void _startSensors() {
|
void _startSensors() {
|
||||||
_gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) {
|
_gyroSub = gyroscopeEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((GyroscopeEvent event) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (_lastUpdate == null) {
|
if (_lastGyroTime == null) {
|
||||||
_lastUpdate = now;
|
_lastGyroTime = now;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dt = now.difference(_lastUpdate!).inMicroseconds / 1000000.0;
|
double dt = now.difference(_lastGyroTime!).inMicroseconds / 1000000.0;
|
||||||
_lastUpdate = now;
|
_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)
|
// 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 gx = event.x * (180.0 / math.pi);
|
||||||
final gy = event.y * (180.0 / math.pi);
|
final gy = event.y * (180.0 / math.pi);
|
||||||
|
|
||||||
_currentPitch += gx * dt;
|
_currentPitch = (_currentPitch + gx * dt).clamp(-90.0, 90.0);
|
||||||
_currentRoll -= gy * dt;
|
_currentRoll = (_currentRoll - gy * dt).clamp(-90.0, 90.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
_accelSub = accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((AccelerometerEvent event) {
|
_accelSub = accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((AccelerometerEvent event) {
|
||||||
// Calculate roll/pitch from accelerometer (accounting for gravity)
|
// Calculate total acceleration magnitude
|
||||||
// Assuming phone is mounted roughly vertical or horizontal, we calculate basic roll
|
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 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);
|
final accelPitch = math.atan2(-event.y, event.z) * (180.0 / math.pi);
|
||||||
|
|
||||||
// Complementary filter: Combine integrated gyro with absolute accel
|
// Dynamic acceleration gating:
|
||||||
if (_lastUpdate != null) {
|
// On a motorcycle, accelerometers measure specific force = gravity + vehicle dynamics.
|
||||||
_currentRoll = _alpha * _currentRoll + (1.0 - _alpha) * accelRoll;
|
// During acceleration, braking, or cornering, the apparent gravity vector tilts significantly.
|
||||||
_currentPitch = _alpha * _currentPitch + (1.0 - _alpha) * accelPitch;
|
// Inhibit accelerometer tilt correction when total force deviates from 1.0g or during motion.
|
||||||
|
final gMag = norm / _gravityConst;
|
||||||
double calibratedLean = _currentRoll - _rollOffset;
|
final dynamicDelta = (gMag - 1.0).abs();
|
||||||
_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) {
|
_userAccelSub = userAccelerometerEventStream(samplingPeriod: const Duration(milliseconds: 20)).listen((UserAccelerometerEvent event) {
|
||||||
// User accelerometer removes gravity.
|
// Project phone linear acceleration vector into motorcycle body frame
|
||||||
// Convert m/s^2 to G's
|
// using calibrated pitch and roll attitude angles
|
||||||
const gravity = 9.80665;
|
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,
|
// Forward/longitudinal acceleration (along motorcycle centerline)
|
||||||
// or Z if horizontal. This requires mounting orientation knowledge.
|
// Positive = Acceleration, Negative = Braking
|
||||||
// Assuming a standard gauge-cluster mount (portrait or landscape).
|
final rawLongitudinal = (event.y * math.cos(pitchRad) - event.z * math.sin(pitchRad));
|
||||||
// For now, we provide the raw components mapped to standard device axes.
|
// Lateral acceleration (cornering)
|
||||||
// E.g., device Y is longitudinal (forward/back), device X is lateral.
|
final rawLateral = (event.x * math.cos(rollRad) + event.z * math.sin(rollRad));
|
||||||
|
|
||||||
double longitudinalG = event.y / gravity;
|
double rawLongitudinalG = rawLongitudinal / _gravityConst;
|
||||||
double lateralG = event.x / gravity;
|
double rawLateralG = rawLateral / _gravityConst;
|
||||||
|
|
||||||
// In a real scenario, these would be transformed by the calibration matrix.
|
// 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;
|
||||||
|
|
||||||
_gForceController.add(GForceData(
|
_gForceController.add(GForceData(
|
||||||
longitudinal: longitudinalG,
|
longitudinal: _filteredLongitudinalG,
|
||||||
lateral: lateralG,
|
lateral: _filteredLateralG,
|
||||||
accelX: event.x,
|
accelX: event.x,
|
||||||
accelY: event.y,
|
accelY: event.y,
|
||||||
accelZ: event.z,
|
accelZ: event.z,
|
||||||
|
|
@ -121,8 +168,10 @@ class SensorFusionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
void calibrateUpright() {
|
void calibrateUpright() {
|
||||||
// Zero out the current lean
|
// Zero out the current lean & pitch and persist to storage
|
||||||
_rollOffset = _currentRoll;
|
_rollOffset = _currentRoll;
|
||||||
|
_pitchOffset = _currentPitch;
|
||||||
|
_calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@ import '../models/telemetry_sample.dart';
|
||||||
import 'location_service.dart';
|
import 'location_service.dart';
|
||||||
import 'sensor_fusion_service.dart';
|
import 'sensor_fusion_service.dart';
|
||||||
import 'compass_service.dart';
|
import 'compass_service.dart';
|
||||||
|
import 'field_test/field_test_logger.dart';
|
||||||
|
|
||||||
final telemetryServiceProvider = Provider<TelemetryService>((ref) {
|
final telemetryServiceProvider = Provider<TelemetryService>((ref) {
|
||||||
final locationService = ref.watch(locationServiceProvider);
|
final locationService = ref.watch(locationServiceProvider);
|
||||||
final sensorFusionService = ref.watch(sensorFusionServiceProvider);
|
final sensorFusionService = ref.watch(sensorFusionServiceProvider);
|
||||||
final compassService = ref.watch(compassServiceProvider);
|
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());
|
ref.onDispose(() => service.dispose());
|
||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
@ -24,6 +26,7 @@ class TelemetryService {
|
||||||
final LocationService _locationService;
|
final LocationService _locationService;
|
||||||
final SensorFusionService _sensorFusionService;
|
final SensorFusionService _sensorFusionService;
|
||||||
final CompassService _compassService;
|
final CompassService _compassService;
|
||||||
|
final FieldTestLogger _fieldTestLogger;
|
||||||
|
|
||||||
final _telemetryController = StreamController<TelemetrySample>.broadcast();
|
final _telemetryController = StreamController<TelemetrySample>.broadcast();
|
||||||
Stream<TelemetrySample> get telemetryStream => _telemetryController.stream;
|
Stream<TelemetrySample> get telemetryStream => _telemetryController.stream;
|
||||||
|
|
@ -43,11 +46,35 @@ class TelemetryService {
|
||||||
GForceData? _latestGForce;
|
GForceData? _latestGForce;
|
||||||
double _latestCompassHeading = 0.0;
|
double _latestCompassHeading = 0.0;
|
||||||
|
|
||||||
TelemetryService(this._locationService, this._sensorFusionService, this._compassService) {
|
TelemetryService(
|
||||||
|
this._locationService,
|
||||||
|
this._sensorFusionService,
|
||||||
|
this._compassService,
|
||||||
|
this._fieldTestLogger,
|
||||||
|
) {
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _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) {
|
_locationSub = _locationService.getFilteredPositionStream().listen((pos) {
|
||||||
_latestPosition = pos;
|
_latestPosition = pos;
|
||||||
});
|
});
|
||||||
|
|
@ -62,6 +89,19 @@ class TelemetryService {
|
||||||
|
|
||||||
_gForceSub = _sensorFusionService.gForceStream.listen((gForce) {
|
_gForceSub = _sensorFusionService.gForceStream.listen((gForce) {
|
||||||
_latestGForce = 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) {
|
_compassSub = _compassService.headingStream.listen((heading) {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
version: "2.0.3"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||||
|
|
@ -566,7 +566,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.2"
|
version: "4.9.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ dependencies:
|
||||||
share_plus: ^13.3.0
|
share_plus: ^13.3.0
|
||||||
http: ^1.3.0
|
http: ^1.3.0
|
||||||
package_info_plus: ^10.2.1
|
package_info_plus: ^10.2.1
|
||||||
|
archive: ^4.0.2
|
||||||
|
intl: ^0.20.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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/open_street_map_provider.dart';
|
||||||
import 'package:motorcycle_dash/services/map/google_map_provider.dart';
|
import 'package:motorcycle_dash/services/map/google_map_provider.dart';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
group('Map Provider Architecture Tests', () {
|
group('Map Provider Architecture Tests', () {
|
||||||
test('AppLatLng equality and initialization', () {
|
test('AppLatLng equality and initialization', () {
|
||||||
const p1 = AppLatLng(37.7749, -122.4194);
|
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