import 'dart:async'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../database/database.dart' hide TelemetrySample; import 'package:drift/drift.dart' as drift; import 'package:geolocator/geolocator.dart'; import '../../models/telemetry_sample.dart'; import '../../models/field_test_event.dart'; import '../../models/recording_health_status.dart'; import '../../services/telemetry_service.dart'; import '../../services/audio_alert_service.dart'; import '../records/records_service.dart'; import '../garage/garage_providers.dart'; import '../../services/field_test/field_test_logger.dart'; import '../../services/github/github_sync_service.dart'; enum RideState { idle, ready, riding, paused, finished } class RideSession { final int? dbId; final RideState state; final RecordingHealthStatus healthStatus; final double tripDistanceMiles; final double topSpeedMph; final double maxLeanLeft; final double maxLeanRight; final double maxAccelG; final double maxBrakingG; final DateTime? startTime; // Phase 4 Stats final int movingTimeSeconds; final int stoppedTimeSeconds; final double elevationGain; final double elevationLoss; final double averageSpeedMph; final int totalSamplesReceived; final int totalSamplesPersisted; RideSession({ this.dbId, this.state = RideState.idle, this.healthStatus = RecordingHealthStatus.recordingArmed, this.tripDistanceMiles = 0.0, this.topSpeedMph = 0.0, this.maxLeanLeft = 0.0, this.maxLeanRight = 0.0, this.maxAccelG = 0.0, this.maxBrakingG = 0.0, this.startTime, this.movingTimeSeconds = 0, this.stoppedTimeSeconds = 0, this.elevationGain = 0.0, this.elevationLoss = 0.0, this.averageSpeedMph = 0.0, this.totalSamplesReceived = 0, this.totalSamplesPersisted = 0, }); RideSession copyWith({ int? dbId, RideState? state, RecordingHealthStatus? healthStatus, double? tripDistanceMiles, double? topSpeedMph, double? maxLeanLeft, double? maxLeanRight, double? maxAccelG, double? maxBrakingG, DateTime? startTime, int? movingTimeSeconds, int? stoppedTimeSeconds, double? elevationGain, double? elevationLoss, double? averageSpeedMph, int? totalSamplesReceived, int? totalSamplesPersisted, }) { return RideSession( dbId: dbId ?? this.dbId, state: state ?? this.state, healthStatus: healthStatus ?? this.healthStatus, tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles, topSpeedMph: topSpeedMph ?? this.topSpeedMph, maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft, maxLeanRight: maxLeanRight ?? this.maxLeanRight, maxAccelG: maxAccelG ?? this.maxAccelG, maxBrakingG: maxBrakingG ?? this.maxBrakingG, startTime: startTime ?? this.startTime, movingTimeSeconds: movingTimeSeconds ?? this.movingTimeSeconds, stoppedTimeSeconds: stoppedTimeSeconds ?? this.stoppedTimeSeconds, elevationGain: elevationGain ?? this.elevationGain, elevationLoss: elevationLoss ?? this.elevationLoss, averageSpeedMph: averageSpeedMph ?? this.averageSpeedMph, totalSamplesReceived: totalSamplesReceived ?? this.totalSamplesReceived, totalSamplesPersisted: totalSamplesPersisted ?? this.totalSamplesPersisted, ); } } class RideStateMachine extends StateNotifier { final AppDatabase db; final TelemetryService telemetryService; final RecordsService recordsService; final AudioAlertService audioAlertService; final Ref ref; StreamSubscription? _telemetrySub; TelemetrySample? _lastSample; double _accumulatedDistance = 0; // meters internally double _movingTimeAccumulator = 0.0; // seconds internally double _stoppedTimeAccumulator = 0.0; // seconds internally // Batching final List _telemetryBatch = []; Timer? _batchWriteTimer; Timer? _watchdogTimer; DateTime? _lastTelemetryReceivedTime; // Event Debouncing (Store last trigger time per event type) final Map _lastEventTimes = {}; static const Duration _eventCooldown = Duration(seconds: 5); RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) { _telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate); // Write to DB every 3 seconds for tight crash-durability _batchWriteTimer = Timer.periodic(const Duration(seconds: 3), (_) => _flushBatch()); // Watchdog timer (every 2 seconds) checks if telemetry pipeline is alive _watchdogTimer = Timer.periodic(const Duration(seconds: 2), (_) => _checkWatchdog()); // Recover any unfinished rides from previous sessions _recoverInterruptedRides(); } void _checkWatchdog() { if (state.state != RideState.riding) return; final now = DateTime.now(); if (_lastTelemetryReceivedTime != null) { final elapsedSec = now.difference(_lastTelemetryReceivedTime!).inSeconds; if (elapsedSec > 4) { // Telemetry pipeline has stalled if (state.healthStatus != RecordingHealthStatus.recordingDegraded) { state = state.copyWith(healthStatus: RecordingHealthStatus.recordingDegraded); try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.telemetryGap, severity: EventSeverity.warning, message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)', ); } catch (_) {} } } } } Future _recoverInterruptedRides() async { try { final unclosedRides = await (db.select(db.rides) ..where((r) => r.endTime.isNull()) ).get(); 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 { final logger = ref.read(fieldTestLoggerProvider); logger.log( eventType: FieldTestEventType.databaseRecovery, severity: EventSeverity.critical, message: 'Interrupted ride #${r.id} recovered after unexpected exit', rideId: r.id, ); await logger.generateAndSaveSummary(r.id, wasRecovered: true); } catch (_) {} } } catch (_) {} } void setReady() { state = state.copyWith( state: RideState.ready, healthStatus: RecordingHealthStatus.recordingArmed, ); } Future startRide() async { if (state.state == RideState.riding) return; try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.rideStartRequested, severity: EventSeverity.info, message: 'Ride start requested by user or motion trigger', ); } catch (_) {} final defaultBike = ref.read(defaultMotorcycleProvider); final id = await db.into(db.rides).insert(RidesCompanion.insert( startTime: DateTime.now(), motorcycleId: drift.Value(defaultBike?.id), motorcycleYear: drift.Value(defaultBike?.year), motorcycleMake: drift.Value(defaultBike?.makeName), motorcycleModel: drift.Value(defaultBike?.modelName), motorcycleVariant: drift.Value(defaultBike?.variantName), )); state = state.copyWith( dbId: id, state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive, startTime: DateTime.now(), tripDistanceMiles: 0, topSpeedMph: 0, maxLeanLeft: 0, maxLeanRight: 0, maxAccelG: 0, maxBrakingG: 0, averageSpeedMph: 0, movingTimeSeconds: 0, stoppedTimeSeconds: 0, elevationGain: 0, elevationLoss: 0, totalSamplesReceived: 0, totalSamplesPersisted: 0, ); _lastSample = null; _accumulatedDistance = 0; _movingTimeAccumulator = 0.0; _stoppedTimeAccumulator = 0.0; _lastTelemetryReceivedTime = DateTime.now(); try { WakelockPlus.enable().catchError((_) {}); } catch (_) {} audioAlertService.speak('Session started'); // Auto-start Field Test Session with full forensic logging try { final logger = ref.read(fieldTestLoggerProvider); logger.startSession(id); logger.log( eventType: FieldTestEventType.foregroundServiceStarted, severity: EventSeverity.info, message: 'Foreground service confirmed active for ride #$id', rideId: id, ); logger.log( eventType: FieldTestEventType.wakelockAcquired, severity: EventSeverity.info, message: 'Screen wakelock acquired for ride #$id', rideId: id, ); logger.log( eventType: FieldTestEventType.telemetryPipelineStarted, severity: EventSeverity.info, message: '20Hz telemetry aggregation pipeline started for ride #$id', rideId: id, ); } catch (_) {} } void pauseRide() { state = state.copyWith(state: RideState.paused); try { WakelockPlus.disable().catchError((_) {}); } catch (_) {} try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.appPaused, severity: EventSeverity.info, message: 'Ride session paused', ); } catch (_) {} } void resumeRide() { state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive); _lastSample = null; // reset to avoid jumping _lastTelemetryReceivedTime = DateTime.now(); try { WakelockPlus.enable().catchError((_) {}); } catch (_) {} try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.appResumed, severity: EventSeverity.info, message: 'Ride session resumed', ); } catch (_) {} } Future finishRide() async { if (state.dbId != null) { state = state.copyWith(healthStatus: RecordingHealthStatus.recordingFinalizing); try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.rideFinishRequested, severity: EventSeverity.info, message: 'Ride finish requested for ride #${state.dbId}', rideId: state.dbId, ); } catch (_) {} await _flushBatch(); // write remaining points await db.endRide( state.dbId!, 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), ), ); try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.rideFinalized, severity: EventSeverity.info, message: 'Ride record #${state.dbId} finalized in database', rideId: state.dbId, ); } catch (_) {} // Check for personal records final runStats = PersonalRecords( topSpeedMph: state.topSpeedMph, maxLeanLeft: state.maxLeanLeft, maxLeanRight: state.maxLeanRight, maxAccelG: state.maxAccelG, maxBrakingG: state.maxBrakingG, longestRideMiles: state.tripDistanceMiles, greatestElevationGain: state.elevationGain, ); await recordsService.checkAndSaveRecords(runStats, (recordName, value) { audioAlertService.speak('New record: $recordName'); }); } // Auto-stop Field Test Session if active try { final logger = ref.read(fieldTestLoggerProvider); if (logger.isSessionActive) { await logger.stopSession(); logger.log( eventType: FieldTestEventType.rideSummaryCreated, severity: EventSeverity.info, message: 'Field test summary generated and saved for ride #${state.dbId}', rideId: state.dbId, ); } } catch (_) {} // Offline-first background synchronization to GitHub (if enabled) final completedRideId = state.dbId; if (completedRideId != null) { try { ref.read(githubSyncServiceProvider).queueRideForUpload(completedRideId); } catch (_) {} } state = state.copyWith( state: RideState.finished, healthStatus: RecordingHealthStatus.recordingComplete, ); try { WakelockPlus.disable().catchError((_) {}); } catch (_) {} audioAlertService.speak('Session ended'); } void reset() { state = RideSession(); } void _onTelemetryUpdate(TelemetrySample sample) { _lastTelemetryReceivedTime = DateTime.now(); // Motion-triggered auto-start safeguard: // If in `ready` or `idle` and vehicle speed is detected >= 2.0 MPH, // automatically promote to active recording so physical rides are NEVER lost! if (state.state == RideState.ready) { if (sample.speed != null && sample.speed! >= 1.5) { startRide(); return; } } if (state.state != RideState.riding) return; if (state.healthStatus != RecordingHealthStatus.recordingActive) { state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive); } _queueForBatch(sample); _processStats(sample); try { if (sample.quality != null) { ref.read(fieldTestLoggerProvider).processTelemetrySample(sample, sample.quality!); } } catch (_) {} } static const int _maxBatchCapacity = 1000; void _queueForBatch(TelemetrySample sample) { if (state.dbId == null) return; // Bound queue to prevent memory leaks during extended DB locks if (_telemetryBatch.length >= _maxBatchCapacity) { _telemetryBatch.removeAt(0); try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.databaseQueueOverflow, message: 'Telemetry batch buffer reached capacity (1000); dropping oldest sample', severity: EventSeverity.warning, ); } catch (_) {} } _telemetryBatch.add( TelemetrySamplesCompanion( rideId: drift.Value(state.dbId!), timestamp: drift.Value(sample.timestamp), latitude: drift.Value(sample.latitude), longitude: drift.Value(sample.longitude), altitude: drift.Value(sample.altitude), speed: drift.Value(sample.speed), heading: drift.Value(sample.heading), gpsAccuracy: drift.Value(sample.gpsAccuracy), leanAngle: drift.Value(sample.leanAngle), pitchAngle: drift.Value(sample.pitchAngle), accelX: drift.Value(sample.accelX), accelY: drift.Value(sample.accelY), accelZ: drift.Value(sample.accelZ), longitudinalG: drift.Value(sample.longitudinalG), lateralG: drift.Value(sample.lateralG), ) ); state = state.copyWith(totalSamplesReceived: state.totalSamplesReceived + 1); } Future _flushBatch() async { if (_telemetryBatch.isNotEmpty) { final batchToInsert = List.from(_telemetryBatch); try { await db.batch((batch) { batch.insertAll(db.telemetrySamples, batchToInsert); }); // Success: clear inserted samples and update persisted count _telemetryBatch.removeRange(0, batchToInsert.length); state = state.copyWith(totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length); } catch (e) { // Keep in queue for retry on next flush interval try { ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.databaseQueueWarning, message: 'Database batch write retry: $e', severity: EventSeverity.warning, ); } catch (_) {} } } // 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) { // Completely ignore rejected samples (NaN, teleportation jumps, extreme spikes) if (sample.quality?.isRejected == true) return; double newTopSpeed = state.topSpeedMph; double newMaxLeanLeft = state.maxLeanLeft; double newMaxLeanRight = state.maxLeanRight; double newMaxAccelG = state.maxAccelG; double newMaxBrakingG = state.maxBrakingG; int newMovingTime = state.movingTimeSeconds; int newStoppedTime = state.stoppedTimeSeconds; double newElevationGain = state.elevationGain; double newElevationLoss = state.elevationLoss; if (sample.speed != null && sample.speed! <= 220.0) { if (sample.speed! > state.topSpeedMph) { newTopSpeed = sample.speed!; } } // Continuous sub-second time accumulation across 20Hz frames if (_lastSample != null && sample.speed != null) { final dt = sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds / 1000.0; if (dt > 0 && dt < 2.0) { if (sample.speed! > 1.8) { _movingTimeAccumulator += dt; } else { _stoppedTimeAccumulator += dt; } newMovingTime = _movingTimeAccumulator.floor(); newStoppedTime = _stoppedTimeAccumulator.floor(); } } // Speed-gate dynamic motorcycle metrics (require speed >= 3.0 MPH to prevent stationary kickstand / dismount artifacts from registering as personal riding records) final isDynamicallyMoving = sample.speed != null && sample.speed! >= 3.0; if (isDynamicallyMoving && sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) { if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) { newMaxLeanLeft = sample.leanAngle!.abs(); } else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) { newMaxLeanRight = sample.leanAngle!; } } if (isDynamicallyMoving && sample.longitudinalG != null && sample.longitudinalG!.abs() <= 2.5) { if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) { newMaxAccelG = sample.longitudinalG!; } else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) { newMaxBrakingG = sample.longitudinalG!.abs(); } } if (sample.latitude != null && sample.longitude != null && _lastSample?.latitude != null && _lastSample?.longitude != null) { final distMeters = Geolocator.distanceBetween( _lastSample!.latitude!, _lastSample!.longitude!, sample.latitude!, sample.longitude!, ); // Accumulate distance ONLY if moving and GPS accuracy is reasonable (suppresses stationary jitter at stop lights) final isMoving = (sample.speed != null && sample.speed! >= 1.0) || (_lastSample!.speed != null && _lastSample!.speed! >= 1.0); final hasGoodGps = sample.gpsAccuracy != null && sample.gpsAccuracy! <= 35.0; if (isMoving && hasGoodGps && distMeters > 0.3 && distMeters < 150.0) { _accumulatedDistance += distMeters; } // Elevation tracking if (sample.altitude != null && _lastSample!.altitude != null) { final elevationDelta = sample.altitude! - _lastSample!.altitude!; // Threshold filter to prevent phantom gain (e.g. > 1 meter change) if (elevationDelta.abs() > 1.0 && elevationDelta.abs() < 50.0) { if (elevationDelta > 0) { newElevationGain += elevationDelta; } else { newElevationLoss += elevationDelta.abs(); } } } } double tripDistanceMiles = _accumulatedDistance / 1609.34; // Calculate avg speed (only moving time) double avgSpeed = 0; if (newMovingTime > 0) { // miles per hour avgSpeed = (tripDistanceMiles / newMovingTime) * 3600; } _detectEvents(sample); _lastSample = sample; state = state.copyWith( topSpeedMph: newTopSpeed, maxLeanLeft: newMaxLeanLeft, maxLeanRight: newMaxLeanRight, maxAccelG: newMaxAccelG, maxBrakingG: newMaxBrakingG, tripDistanceMiles: tripDistanceMiles, movingTimeSeconds: newMovingTime, stoppedTimeSeconds: newStoppedTime, elevationGain: newElevationGain, elevationLoss: newElevationLoss, averageSpeedMph: avgSpeed, ); } void _detectEvents(TelemetrySample sample) { if (state.dbId == null) return; final now = DateTime.now(); void checkAndLogEvent(String type, double value, bool condition) { if (condition) { final lastTime = _lastEventTimes[type]; if (lastTime == null || now.difference(lastTime) > _eventCooldown) { _lastEventTimes[type] = now; db.into(db.rideEvents).insert(RideEventsCompanion.insert( rideId: state.dbId!, timestamp: now, eventType: type, value: value, latitude: drift.Value(sample.latitude), longitude: drift.Value(sample.longitude), )); } } } if (sample.longitudinalG != null) { checkAndLogEvent('Hard Braking', sample.longitudinalG!, sample.longitudinalG! < -0.8); checkAndLogEvent('Hard Acceleration', sample.longitudinalG!, sample.longitudinalG! > 0.6); } if (sample.leanAngle != null) { checkAndLogEvent('Extreme Lean', sample.leanAngle!, sample.leanAngle!.abs() > 45.0); } if (sample.speed != null) { checkAndLogEvent('High Speed', sample.speed!, sample.speed! > 100.0); } } @override void dispose() { _telemetrySub?.cancel(); _batchWriteTimer?.cancel(); _watchdogTimer?.cancel(); super.dispose(); } } final rideStateMachineProvider = StateNotifierProvider((ref) { final db = ref.watch(databaseProvider); final telemetryService = ref.watch(telemetryServiceProvider); final recordsService = ref.watch(recordsServiceProvider.notifier); final audioAlertService = ref.watch(audioAlertServiceProvider); return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref); });