import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:drift/drift.dart' as drift; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/version/app_identity.dart'; import '../../database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample; import '../../models/field_test_event.dart'; import '../../models/field_test_summary.dart'; import '../../models/telemetry_sample.dart'; import '../validation/telemetry_validator.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.onDispose(() => logger.dispose()); return logger; }); final activeFieldTestSessionProvider = Provider((ref) { return ref.watch(fieldTestLoggerProvider).activeRideId; }); class FieldTestLogger { final AppDatabase _db; int? _activeRideId; int? get activeRideId => _activeRideId; bool get isSessionActive => _activeRideId != null; DateTime? _sessionStartTime; DateTime? get sessionStartTime => _sessionStartTime; // In-memory circular buffer for UI timeline (latest 500 events) final ListQueue _timelineBuffer = ListQueue(500); List get timelineEvents => _timelineBuffer.toList(); final _timelineStreamController = StreamController>.broadcast(); Stream> get timelineStream => _timelineStreamController.stream; // Bounded batch queue for database persistence (Capacity: 1,000 events) static const int _maxQueueCapacity = 1000; final List _eventBatch = []; Timer? _flushTimer; bool _isFlushing = false; // Requirement 6: Memory / Queue / Database Observability Metrics int _peakQueueDepth = 0; int _totalEnqueued = 0; int _totalFlushed = 0; int _droppedEventsCount = 0; String? _oldestDroppedItem; String? _newestDroppedItem; int _lastFlushDurationMs = 0; int _failedFlushCount = 0; int get currentQueueDepth => _eventBatch.length; int get peakQueueDepth => _peakQueueDepth; int get queueCapacity => _maxQueueCapacity; int get totalEnqueued => _totalEnqueued; int get totalFlushed => _totalFlushed; int get droppedEventsCount => _droppedEventsCount; String? get oldestDroppedItem => _oldestDroppedItem; String? get newestDroppedItem => _newestDroppedItem; int get lastFlushDurationMs => _lastFlushDurationMs; int get failedFlushCount => _failedFlushCount; // Requirement 4 & 5: Live Sensor & Timing Health Metrics DateTime? _lastSampleTimestamp; DateTime? _lastGpsTimestamp; double _recentSampleRateHz = 0.0; int _recentSampleCount = 0; DateTime? _rateCalcWindowStart; double _lastGpsAccuracy = 0.0; int _lastGpsAgeMs = 0; double _timingJitterSumMs = 0.0; int _timingJitterCount = 0; double _currentQualityScore = 100.0; double get currentSampleRateHz => _recentSampleRateHz; double get lastGpsAccuracy => _lastGpsAccuracy; int get lastGpsAgeMs => _lastGpsAgeMs; double get currentQualityEstimate => _currentQualityScore; double get averageTimingJitterMs => _timingJitterCount > 0 ? _timingJitterSumMs / _timingJitterCount : 0.0; // State-transition tracking for De-duplication bool _isGpsStale = false; DateTime? _gpsStaleStartTime; int _gpsStaleMaxAgeMs = 0; double? _gpsStaleMinAccuracy; double? _gpsStaleMaxAccuracy; double? _gpsStaleSpeed; bool _isGpsAccuracyDegraded = false; bool _isImuSaturated = false; bool _isTelemetryGap = false; DateTime? _telemetryGapStartTime; int _maxTelemetryGapMs = 0; DateTime? _lastTelemetryTime; int _telemetryGapCount = 0; int _gpsStaleCount = 0; int _gpsJumpRejectCount = 0; int _imuSaturationCount = 0; int _accelerationEventCount = 0; int _brakingEventCount = 0; FieldTestLogger(this._db) { _flushTimer = Timer.periodic(const Duration(seconds: 3), (_) { flush(); }); } AppIdentity? _sessionIdentity; AppIdentity get sessionIdentity => _sessionIdentity ?? AppIdentity.current(); void startSession(int rideId, {String? notes, DateTime? timestamp}) { _activeRideId = rideId; _sessionStartTime = timestamp ?? DateTime.now(); _sessionIdentity = AppIdentity.current(); _timelineBuffer.clear(); _eventBatch.clear(); _resetStateTrackers(); log( eventType: FieldTestEventType.rideStarted, severity: EventSeverity.info, message: 'Field test session started for ride #$rideId [v${sessionIdentity.displayString}]', rideId: rideId, timestamp: _sessionStartTime, metadata: { 'software': sessionIdentity.toJson(), 'notes': ?notes, }, ); } Future stopSession() async { final rideId = _activeRideId; if (rideId == null) { throw StateError('No active field test session to stop'); } log( eventType: FieldTestEventType.rideStopped, severity: EventSeverity.info, message: 'Field test session completed for ride #$rideId', rideId: rideId, ); // Final database flush before generating summary await flush(); final summary = await generateAndSaveSummary(rideId); _activeRideId = null; _sessionStartTime = null; return summary; } void _resetStateTrackers() { _isGpsStale = false; _gpsStaleStartTime = null; _gpsStaleMaxAgeMs = 0; _gpsStaleMinAccuracy = null; _gpsStaleMaxAccuracy = null; _gpsStaleSpeed = null; _isGpsAccuracyDegraded = false; _isImuSaturated = false; _isTelemetryGap = false; _telemetryGapStartTime = null; _maxTelemetryGapMs = 0; _lastTelemetryTime = null; _lastSampleTimestamp = null; _lastGpsTimestamp = null; _telemetryGapCount = 0; _gpsStaleCount = 0; _gpsJumpRejectCount = 0; _imuSaturationCount = 0; _accelerationEventCount = 0; _brakingEventCount = 0; _peakQueueDepth = 0; _totalEnqueued = 0; _totalFlushed = 0; _droppedEventsCount = 0; _oldestDroppedItem = null; _newestDroppedItem = null; _failedFlushCount = 0; _recentSampleRateHz = 0.0; _recentSampleCount = 0; _rateCalcWindowStart = null; _timingJitterSumMs = 0.0; _timingJitterCount = 0; _currentQualityScore = 100.0; } /// Non-blocking centralized logging entrypoint void log({ required FieldTestEventType eventType, required EventSeverity severity, required String message, int? rideId, DateTime? timestamp, DateTime? telemetryTimestamp, int? gpsAgeMs, double? speed, double? latitude, double? longitude, double? gpsAccuracy, double? leanAngle, double? longitudinalG, double? lateralG, double? overallConfidence, Map? metadata, }) { final effectiveRideId = rideId ?? _activeRideId; final now = timestamp ?? DateTime.now(); final event = FieldTestEvent( timestamp: now, eventType: eventType, rideId: effectiveRideId, severity: severity, message: message, telemetryTimestamp: telemetryTimestamp, gpsAgeMs: gpsAgeMs, speed: speed, latitude: latitude, longitude: longitude, gpsAccuracy: gpsAccuracy, leanAngle: leanAngle, longitudinalG: longitudinalG, lateralG: lateralG, overallConfidence: overallConfidence, metadata: metadata, ); // 1. Add to live in-memory timeline buffer if (_timelineBuffer.length >= 500) { _timelineBuffer.removeFirst(); } _timelineBuffer.add(event); if (!_timelineStreamController.isClosed) { _timelineStreamController.add(_timelineBuffer.toList()); } // 2. Queue for asynchronous batch persistence _enqueueEvent(event); } void _enqueueEvent(FieldTestEvent event) { _totalEnqueued++; if (_eventBatch.length >= _maxQueueCapacity) { // Prioritized Drop Policy: Drop lowest priority info events first final dropIdx = _eventBatch.indexWhere((e) => e.severity.value == EventSeverity.info.displayName); if (dropIdx != -1) { final dropped = _eventBatch.removeAt(dropIdx); _recordDrop(dropped.eventType.value); } else if (event.severity == EventSeverity.critical) { final dropped = _eventBatch.removeAt(0); _recordDrop(dropped.eventType.value); } else { _recordDrop(event.eventType.displayName); return; } } _eventBatch.add(FieldTestEventsCompanion( rideId: drift.Value(event.rideId), timestamp: drift.Value(event.timestamp), eventType: drift.Value(event.eventType.displayName), severity: drift.Value(event.severity.displayName), message: drift.Value(event.message), telemetryTimestamp: drift.Value(event.telemetryTimestamp), gpsAgeMs: drift.Value(event.gpsAgeMs), speed: drift.Value(event.speed), latitude: drift.Value(event.latitude), longitude: drift.Value(event.longitude), gpsAccuracy: drift.Value(event.gpsAccuracy), leanAngle: drift.Value(event.leanAngle), longitudinalG: drift.Value(event.longitudinalG), lateralG: drift.Value(event.lateralG), overallConfidence: drift.Value(event.overallConfidence), metadata: drift.Value(event.metadata != null ? jsonEncode(event.metadata) : null), )); if (_eventBatch.length > _peakQueueDepth) { _peakQueueDepth = _eventBatch.length; } } void _recordDrop(String eventType) { _droppedEventsCount++; _oldestDroppedItem ??= eventType; _newestDroppedItem = eventType; } /// Process live telemetry sample and perform de-duplicated event generation void processTelemetrySample(TelemetrySample sample, TelemetryQuality quality) { final now = sample.timestamp; // Requirement 4 & 5: Live sample rate & jitter calculation _recentSampleCount++; if (_rateCalcWindowStart == null) { _rateCalcWindowStart = now; } else { final windowSec = now.difference(_rateCalcWindowStart!).inMilliseconds / 1000.0; if (windowSec >= 1.0) { _recentSampleRateHz = _recentSampleCount / windowSec; _recentSampleCount = 0; _rateCalcWindowStart = now; } } if (sample.gpsAccuracy != null) _lastGpsAccuracy = sample.gpsAccuracy!; if (sample.gpsAgeMs != null) _lastGpsAgeMs = sample.gpsAgeMs!; _currentQualityScore = quality.overallConfidence * 100.0; // 1. Timing Forensics: Check for timestamp regressions if (_lastSampleTimestamp != null && now.isBefore(_lastSampleTimestamp!)) { log( eventType: FieldTestEventType.timestampError, severity: EventSeverity.error, message: 'Sample timestamp regression detected ($now < $_lastSampleTimestamp)', telemetryTimestamp: now, ); } if (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp!.isBefore(_lastGpsTimestamp!)) { log( eventType: FieldTestEventType.timestampError, severity: EventSeverity.warning, message: 'GPS fix timestamp regression (${sample.gpsTimestamp} < $_lastGpsTimestamp)', telemetryTimestamp: now, ); } if (sample.gpsTimestamp != null) _lastGpsTimestamp = sample.gpsTimestamp; // 2. Timing Jitter tracking (nominal interval 50ms) if (_lastSampleTimestamp != null) { final dtMs = now.difference(_lastSampleTimestamp!).inMilliseconds; if (dtMs > 0 && dtMs < 500) { final jitter = (dtMs - 50).abs().toDouble(); _timingJitterSumMs += jitter; _timingJitterCount++; } } _lastSampleTimestamp = now; // 3. Telemetry Gap Detection & Recovery if (_lastTelemetryTime != null) { final deltaMs = now.difference(_lastTelemetryTime!).inMilliseconds; if (deltaMs > 2000) { _telemetryGapCount++; if (deltaMs > _maxTelemetryGapMs) _maxTelemetryGapMs = deltaMs; if (!_isTelemetryGap) { _isTelemetryGap = true; _telemetryGapStartTime = _lastTelemetryTime; log( eventType: FieldTestEventType.telemetryGap, severity: EventSeverity.warning, message: 'Telemetry stream gap detected (${deltaMs}ms)', telemetryTimestamp: sample.timestamp, speed: sample.speed, metadata: {'gapMs': deltaMs}, ); } } else if (_isTelemetryGap && deltaMs < 200) { _isTelemetryGap = false; final gapDuration = _telemetryGapStartTime != null ? now.difference(_telemetryGapStartTime!).inMilliseconds / 1000.0 : 0.0; log( eventType: FieldTestEventType.telemetryRecovered, severity: EventSeverity.info, message: 'Telemetry stream recovered after ${gapDuration.toStringAsFixed(1)}s', telemetryTimestamp: sample.timestamp, speed: sample.speed, metadata: {'gapDurationSeconds': gapDuration}, ); } } _lastTelemetryTime = now; // 4. GPS Freshness / Stale De-duplication final gpsAge = sample.gpsAgeMs ?? 0; final speedMph = sample.speed ?? 0.0; if (gpsAge > 2000 && speedMph > 3.0) { if (!_isGpsStale) { _isGpsStale = true; _gpsStaleStartTime = now; _gpsStaleMaxAgeMs = gpsAge; _gpsStaleMinAccuracy = sample.gpsAccuracy; _gpsStaleMaxAccuracy = sample.gpsAccuracy; _gpsStaleSpeed = speedMph; _gpsStaleCount++; log( eventType: FieldTestEventType.gpsStale, severity: EventSeverity.warning, message: 'GPS fix became stale (${gpsAge}ms age at ${speedMph.toStringAsFixed(1)} MPH)', telemetryTimestamp: sample.timestamp, gpsAgeMs: gpsAge, speed: speedMph, gpsAccuracy: sample.gpsAccuracy, latitude: sample.latitude, longitude: sample.longitude, ); } else { if (gpsAge > _gpsStaleMaxAgeMs) _gpsStaleMaxAgeMs = gpsAge; if (sample.gpsAccuracy != null) { if (_gpsStaleMinAccuracy == null || sample.gpsAccuracy! < _gpsStaleMinAccuracy!) { _gpsStaleMinAccuracy = sample.gpsAccuracy; } if (_gpsStaleMaxAccuracy == null || sample.gpsAccuracy! > _gpsStaleMaxAccuracy!) { _gpsStaleMaxAccuracy = sample.gpsAccuracy; } } } } else if (_isGpsStale && gpsAge <= 500) { _isGpsStale = false; final staleDuration = _gpsStaleStartTime != null ? now.difference(_gpsStaleStartTime!).inMilliseconds / 1000.0 : 0.0; log( eventType: FieldTestEventType.gpsRecovered, severity: EventSeverity.info, message: 'GPS fix recovered after ${staleDuration.toStringAsFixed(1)}s (max age ${_gpsStaleMaxAgeMs}ms)', telemetryTimestamp: sample.timestamp, gpsAgeMs: gpsAge, speed: speedMph, gpsAccuracy: sample.gpsAccuracy, latitude: sample.latitude, longitude: sample.longitude, metadata: { 'staleStartTime': _gpsStaleStartTime?.toIso8601String(), 'staleDurationSeconds': staleDuration, 'maxGpsAgeMs': _gpsStaleMaxAgeMs, 'minAccuracy': _gpsStaleMinAccuracy, 'maxAccuracy': _gpsStaleMaxAccuracy, 'speedDuringOutage': _gpsStaleSpeed, }, ); } // 5. GPS Accuracy Degradation De-duplication final accuracy = sample.gpsAccuracy ?? 0.0; if (accuracy > 35.0) { if (!_isGpsAccuracyDegraded) { _isGpsAccuracyDegraded = true; log( eventType: FieldTestEventType.gpsAccuracyDegraded, severity: EventSeverity.warning, message: 'GPS accuracy degraded to ${accuracy.toStringAsFixed(1)}m', telemetryTimestamp: sample.timestamp, gpsAccuracy: accuracy, speed: speedMph, ); } } else if (_isGpsAccuracyDegraded && accuracy <= 15.0) { _isGpsAccuracyDegraded = false; log( eventType: FieldTestEventType.gpsRecovered, severity: EventSeverity.info, message: 'GPS accuracy restored to precision ${accuracy.toStringAsFixed(1)}m', telemetryTimestamp: sample.timestamp, gpsAccuracy: accuracy, speed: speedMph, ); } // 6. IMU Saturation De-duplication if (quality.isSensorSaturated) { _imuSaturationCount++; if (!_isImuSaturated) { _isImuSaturated = true; log( eventType: FieldTestEventType.imuSaturation, severity: EventSeverity.error, message: 'IMU accelerometer saturation limit exceeded (>120 m/s²)', telemetryTimestamp: sample.timestamp, longitudinalG: sample.longitudinalG, lateralG: sample.lateralG, ); } } else if (_isImuSaturated) { _isImuSaturated = false; log( eventType: FieldTestEventType.imuRecovered, severity: EventSeverity.info, message: 'IMU accelerometer returned to normal operating range', telemetryTimestamp: sample.timestamp, ); } // 7. GPS Jump Rejection if (quality.reasons.any((r) => r.contains('jump') || r.contains('Teleportation'))) { _gpsJumpRejectCount++; log( eventType: FieldTestEventType.gpsJumpRejected, severity: EventSeverity.error, message: 'Impossible GPS velocity/position jump rejected', telemetryTimestamp: sample.timestamp, speed: speedMph, latitude: sample.latitude, longitude: sample.longitude, ); } // 8. Significant Acceleration / Braking Events final longG = sample.longitudinalG ?? 0.0; if (longG > 0.60 && speedMph > 5.0) { _accelerationEventCount++; log( eventType: FieldTestEventType.accelerationEvent, severity: EventSeverity.info, message: 'Hard Acceleration Event: ${longG.toStringAsFixed(2)}g at ${speedMph.toStringAsFixed(1)} MPH', telemetryTimestamp: sample.timestamp, speed: speedMph, longitudinalG: longG, lateralG: sample.lateralG, ); } else if (longG < -0.80 && speedMph > 5.0) { _brakingEventCount++; log( eventType: FieldTestEventType.brakingEvent, severity: EventSeverity.info, message: 'Hard Braking Event: ${longG.toStringAsFixed(2)}g at ${speedMph.toStringAsFixed(1)} MPH', telemetryTimestamp: sample.timestamp, speed: speedMph, longitudinalG: longG, lateralG: sample.lateralG, ); } // 9. Sample Validation Status if (quality.status == ValidationStatus.rejected) { log( eventType: FieldTestEventType.sampleRejected, severity: EventSeverity.error, message: 'Sample rejected: ${quality.reasons.join(", ")}', telemetryTimestamp: sample.timestamp, speed: speedMph, overallConfidence: quality.overallConfidence, ); } } /// Flushes buffered events to SQLite in a single transaction Future flush() async { if (_isFlushing || _eventBatch.isEmpty) return; _isFlushing = true; final stopwatch = Stopwatch()..start(); try { final toWrite = List.from(_eventBatch); _eventBatch.clear(); await _db.batch((batch) { batch.insertAll(_db.fieldTestEvents, toWrite); }); _totalFlushed += toWrite.length; stopwatch.stop(); _lastFlushDurationMs = stopwatch.elapsedMilliseconds; } catch (e) { _failedFlushCount++; if (kDebugMode) print('[FieldTestLogger] Flush error: $e'); } finally { _isFlushing = false; } } /// Generates forensic summary from recorded ride telemetry and events Future generateAndSaveSummary(int rideId, {bool wasRecovered = false}) async { final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); final startTime = ride?.startTime ?? DateTime.now(); final endTime = ride?.endTime ?? DateTime.now(); final duration = endTime.difference(startTime); // Query all samples for this ride final samples = await (_db.select(_db.telemetrySamples) ..where((t) => t.rideId.equals(rideId)) ..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)]) ).get(); final totalSamples = samples.length; int validSamples = 0; int suspectSamples = 0; int rejectedSamples = 0; int gpsFixCount = 0; double accuracySum = 0.0; double? worstAccuracy; int? maxGpsAge; double maxSpeed = 0.0; double maxLean = 0.0; double maxLongG = 0.0; double maxLatG = 0.0; final validator = TelemetryValidator(); for (final s in samples) { final sample = TelemetrySample( timestamp: s.timestamp, latitude: s.latitude, longitude: s.longitude, speed: s.speed, heading: s.heading, gpsAccuracy: s.gpsAccuracy, leanAngle: s.leanAngle, longitudinalG: s.longitudinalG, lateralG: s.lateralG, ); final q = validator.validate(sample); if (q.isValid) { validSamples++; } else if (q.isSuspect) { suspectSamples++; } else { rejectedSamples++; } if (s.latitude != null && s.longitude != null) { gpsFixCount++; } if (s.gpsAccuracy != null) { accuracySum += s.gpsAccuracy!; if (worstAccuracy == null || s.gpsAccuracy! > worstAccuracy) { worstAccuracy = s.gpsAccuracy; } } final speedVal = s.speed ?? 0.0; final isMoving = speedVal >= 3.0; if (s.speed != null && s.speed! > maxSpeed && s.speed! < 223.7) { maxSpeed = s.speed!; } if (s.leanAngle != null && s.leanAngle!.abs() <= 65.0) { if (isMoving && s.leanAngle!.abs() > maxLean) { maxLean = s.leanAngle!.abs(); } } if (s.longitudinalG != null && s.longitudinalG!.abs() <= 2.5) { if (isMoving && s.longitudinalG!.abs() > maxLongG) { maxLongG = s.longitudinalG!.abs(); } } if (s.lateralG != null && s.lateralG!.abs() <= 2.5) { if (isMoving && s.lateralG!.abs() > maxLatG) { maxLatG = s.lateralG!.abs(); } } } final validPct = totalSamples > 0 ? (validSamples / totalSamples) * 100.0 : 100.0; final suspectPct = totalSamples > 0 ? (suspectSamples / totalSamples) * 100.0 : 0.0; final rejectedPct = totalSamples > 0 ? (rejectedSamples / totalSamples) * 100.0 : 0.0; final avgAccuracy = gpsFixCount > 0 ? accuracySum / gpsFixCount : null; // Performance Runs for this ride final perfRuns = await (_db.select(_db.performanceRuns) ..where((p) => p.rideId.equals(rideId)) ).get(); final zeroTo60Runs = perfRuns.where((p) => p.type == '0-60').toList(); final zeroTo100Runs = perfRuns.where((p) => p.type == '0-100').toList(); double? best0to60; double? best0to100; for (final r in zeroTo60Runs) { if (best0to60 == null || r.timeMs < best0to60) best0to60 = r.timeMs; } for (final r in zeroTo100Runs) { if (best0to100 == null || r.timeMs < best0to100) best0to100 = r.timeMs; } // Query events count final events = await (_db.select(_db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get(); final qualityReport = RideQualityReport.evaluate( totalSamples: totalSamples, validSamples: validSamples, suspectSamples: suspectSamples, rejectedSamples: rejectedSamples, timestampGaps: _telemetryGapCount, gpsFixCount: gpsFixCount, totalGpsAccuracy: accuracySum, databaseSamplesLost: _droppedEventsCount, imuSaturationCount: _imuSaturationCount, isMountCalibrated: true, ); final summary = FieldTestSummary( rideId: rideId, startTime: startTime, endTime: endTime, duration: duration, totalSamples: totalSamples, validSamples: validSamples, suspectSamples: suspectSamples, rejectedSamples: rejectedSamples, validPercentage: validPct, suspectPercentage: suspectPct, rejectedPercentage: rejectedPct, gpsFixCount: gpsFixCount, gpsStaleEventCount: _gpsStaleCount, gpsJumpRejectCount: _gpsJumpRejectCount, maxGpsAgeMs: maxGpsAge, averageGpsAccuracy: avgAccuracy, worstGpsAccuracy: worstAccuracy, telemetryGapCount: _telemetryGapCount, maxTelemetryGapMs: _maxTelemetryGapMs > 0 ? _maxTelemetryGapMs : null, imuSaturationCount: _imuSaturationCount, maxSpeedMph: maxSpeed > 0 ? maxSpeed : null, maxLeanAngle: maxLean > 0 ? maxLean : null, maxLongitudinalG: maxLongG > 0 ? maxLongG : null, maxLateralG: maxLatG > 0 ? maxLatG : null, accelerationEventCount: _accelerationEventCount, brakingEventCount: _brakingEventCount, zeroTo60Count: zeroTo60Runs.length, zeroTo100Count: zeroTo100Runs.length, bestZeroTo60: best0to60, bestZeroTo100: best0to100, databaseSamplesWritten: totalSamples, databaseSamplesLost: _droppedEventsCount, eventCount: events.length, overallConfidence: qualityReport.overallScore / 100.0, rideQualityScore: qualityReport.overallScore, rideQualityRating: qualityReport.rating, wasRecoveredAfterCrash: wasRecovered, wasInterrupted: ride?.endTime == null || wasRecovered, exportIntegrity: 'VERIFIED_OK', ); // Save summary to database await _db.into(_db.fieldTestSummaries).insertOnConflictUpdate( FieldTestSummariesCompanion.insert( rideId: rideId, startTime: startTime, endTime: drift.Value(endTime), durationSeconds: drift.Value(duration.inSeconds), totalSamples: drift.Value(totalSamples), validSamples: drift.Value(validSamples), suspectSamples: drift.Value(suspectSamples), rejectedSamples: drift.Value(rejectedSamples), validPercentage: drift.Value(validPct), suspectPercentage: drift.Value(suspectPct), rejectedPercentage: drift.Value(rejectedPct), gpsFixCount: drift.Value(gpsFixCount), gpsStaleEventCount: drift.Value(_gpsStaleCount), gpsJumpRejectCount: drift.Value(_gpsJumpRejectCount), maxGpsAgeMs: drift.Value(maxGpsAge), averageGpsAccuracy: drift.Value(avgAccuracy), worstGpsAccuracy: drift.Value(worstAccuracy), telemetryGapCount: drift.Value(_telemetryGapCount), maxTelemetryGapMs: drift.Value(_maxTelemetryGapMs > 0 ? _maxTelemetryGapMs : null), imuSaturationCount: drift.Value(_imuSaturationCount), maxSpeedMph: drift.Value(maxSpeed > 0 ? maxSpeed : null), maxLeanAngle: drift.Value(maxLean > 0 ? maxLean : null), maxLongitudinalG: drift.Value(maxLongG > 0 ? maxLongG : null), maxLateralG: drift.Value(maxLatG > 0 ? maxLatG : null), accelerationEventCount: drift.Value(_accelerationEventCount), brakingEventCount: drift.Value(_brakingEventCount), zeroTo60Count: drift.Value(zeroTo60Runs.length), zeroTo100Count: drift.Value(zeroTo100Runs.length), bestZeroTo60: drift.Value(best0to60), bestZeroTo100: drift.Value(best0to100), databaseSamplesWritten: drift.Value(totalSamples), databaseSamplesLost: drift.Value(_droppedEventsCount), eventCount: drift.Value(events.length), overallConfidence: drift.Value(qualityReport.overallScore / 100.0), rideQualityScore: drift.Value(qualityReport.overallScore.toDouble()), rideQualityRating: drift.Value(qualityReport.rating), wasRecoveredAfterCrash: drift.Value(wasRecovered), wasInterrupted: drift.Value(ride?.endTime == null || wasRecovered), exportIntegrity: const drift.Value('VERIFIED_OK'), ), ); return summary; } Future clearAllSessions() async { await _db.delete(_db.fieldTestEvents).go(); await _db.delete(_db.fieldTestSummaries).go(); _timelineBuffer.clear(); if (!_timelineStreamController.isClosed) { _timelineStreamController.add([]); } } void dispose() { _flushTimer?.cancel(); flush(); _timelineStreamController.close(); } }