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 '../../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/field_test/field_test_models.dart'; enum RideState { idle, ready, riding, paused, finished } class RideSession { final int? dbId; final RideState state; 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; RideSession({ this.dbId, this.state = RideState.idle, 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, }); RideSession copyWith({ int? dbId, RideState? state, double? tripDistanceMiles, double? topSpeedMph, double? maxLeanLeft, double? maxLeanRight, double? maxAccelG, double? maxBrakingG, DateTime? startTime, int? movingTimeSeconds, int? stoppedTimeSeconds, double? elevationGain, double? elevationLoss, double? averageSpeedMph, }) { return RideSession( dbId: dbId ?? this.dbId, state: state ?? this.state, 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, ); } } 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 // Batching final List _telemetryBatch = []; Timer? _batchWriteTimer; // 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 5 seconds _batchWriteTimer = Timer.periodic(const Duration(seconds: 5), (_) => _flushBatch()); // Recover any unfinished rides from previous sessions _recoverInterruptedRides(); } Future _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() { state = state.copyWith(state: RideState.ready); } Future startRide() async { 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, startTime: DateTime.now(), tripDistanceMiles: 0, topSpeedMph: 0, movingTimeSeconds: 0, stoppedTimeSeconds: 0, elevationGain: 0, elevationLoss: 0, ); _lastSample = null; _accumulatedDistance = 0; WakelockPlus.enable(); audioAlertService.speak('Session started'); // Auto-start Black-Box Field Test Session if enabled try { final isFieldTestEnabled = ref.read(fieldTestModeEnabledProvider); if (isFieldTestEnabled) { await ref.read(fieldTestLoggerProvider).startSession( rideId: id, bike: defaultBike, ); } } catch (_) {} } void pauseRide() { state = state.copyWith(state: RideState.paused); WakelockPlus.disable(); try { ref.read(fieldTestLoggerProvider).logLifecycle('Ride Paused'); } catch (_) {} } void resumeRide() { state = state.copyWith(state: RideState.riding); _lastSample = null; // reset to avoid jumping WakelockPlus.enable(); try { ref.read(fieldTestLoggerProvider).logLifecycle('Ride Resumed'); } catch (_) {} } Future finishRide() async { if (state.dbId != null) { _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), ), ); // 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 Black-Box Field Test Session if active try { if (ref.read(fieldTestLoggerProvider).isSessionActive) { await ref.read(fieldTestLoggerProvider).stopSession(); } } catch (_) {} state = state.copyWith(state: RideState.finished); WakelockPlus.disable(); audioAlertService.speak('Session ended'); } void reset() { state = RideSession(); } void _onTelemetryUpdate(TelemetrySample sample) { if (state.state != RideState.riding) return; _queueForBatch(sample); _processStats(sample); } 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).logEvent( eventType: FieldTestEventType.databaseWriteFailure, message: 'Telemetry batch buffer reached capacity (1000); dropping oldest sample', severity: FieldTestSeverity.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), ) ); } 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 _telemetryBatch.removeRange(0, batchToInsert.length); } catch (e) { // Keep in queue for retry on next flush interval try { ref.read(fieldTestLoggerProvider).logEvent( eventType: FieldTestEventType.databaseWriteFailure, message: 'Database batch write retry: $e', severity: FieldTestSeverity.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) { 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) { if (sample.speed! > state.topSpeedMph) { newTopSpeed = sample.speed!; } // Moving vs Stopped Time if (_lastSample != null) { if (sample.speed! > 2.0) { // See detailed internal double tracking below } } } // Better Time tracking logic (internal doubles) double internalMoving = state.movingTimeSeconds.toDouble(); double internalStopped = state.stoppedTimeSeconds.toDouble(); if (_lastSample != null && sample.speed != null) { final dt = sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds / 1000.0; if (sample.speed! > 2.0) { internalMoving += dt; } else { internalStopped += dt; } newMovingTime = internalMoving.floor(); newStoppedTime = internalStopped.floor(); } if (sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) { if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) { newMaxLeanLeft = sample.leanAngle!.abs(); } else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) { newMaxLeanRight = sample.leanAngle!; } } if (sample.longitudinalG != null && sample.longitudinalG!.abs() <= 2.5) { if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) { newMaxAccelG = sample.longitudinalG!; } else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) { 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!, ); _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) { 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(); 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); });