MotorSignal-App/lib/features/rides/ride_state_machine.dart

521 lines
18 KiB
Dart

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 '../../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';
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<RideSession> {
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<TelemetrySamplesCompanion> _telemetryBatch = [];
Timer? _batchWriteTimer;
// Event Debouncing (Store last trigger time per event type)
final Map<String, DateTime> _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<void> _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);
}
Future<void> 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 Field Test Session
try {
ref.read(fieldTestLoggerProvider).startSession(id);
} catch (_) {}
}
void pauseRide() {
state = state.copyWith(state: RideState.paused);
WakelockPlus.disable();
try {
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.appPaused,
severity: EventSeverity.info,
message: 'Ride session paused',
);
} catch (_) {}
}
void resumeRide() {
state = state.copyWith(state: RideState.riding);
_lastSample = null; // reset to avoid jumping
WakelockPlus.enable();
try {
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.appResumed,
severity: EventSeverity.info,
message: 'Ride session resumed',
);
} catch (_) {}
}
Future<void> 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 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);
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),
)
);
}
Future<void> _flushBatch() async {
if (_telemetryBatch.isNotEmpty) {
final batchToInsert = List<TelemetrySamplesCompanion>.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).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!;
}
}
// 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 (dt > 0 && dt < 2.0) {
if (sample.speed! > 1.8) {
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!,
);
// 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();
super.dispose();
}
}
final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSession>((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);
});