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((ref) { return FieldTestModeNotifier(); }); class FieldTestModeNotifier extends StateNotifier { static const _prefKey = 'field_test_mode_enabled'; FieldTestModeNotifier() : super(false) { _load(); } Future _load() async { try { final prefs = await SharedPreferences.getInstance(); state = prefs.getBool(_prefKey) ?? false; } catch (_) {} } Future setEnabled(bool enabled) async { state = enabled; try { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefKey, enabled); } catch (_) {} } } final fieldTestLoggerProvider = Provider((ref) { final db = ref.watch(databaseProvider); final logger = FieldTestLogger(db, ref); ref.onDispose(() => logger.dispose()); return logger; }); final activeFieldTestSessionProvider = StateProvider((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 _liveLogBuffer = ListQueue(500); List get liveLogs => _liveLogBuffer.toList(); final _liveLogStreamController = StreamController>.broadcast(); Stream> get liveLogStream => _liveLogStreamController.stream; // Batches final List _eventBatch = []; final List _gpsBatch = []; final List _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 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 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? 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 flush() async { if (_isDisposed) return; if (_eventBatch.isEmpty && _gpsBatch.isEmpty && _imuBatch.isEmpty) return; final start = DateTime.now(); _dbBatchCount++; final eventsToInsert = List.from(_eventBatch); final gpsToInsert = List.from(_gpsBatch); final imuToInsert = List.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 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 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 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, }); }