MotorSignal-App/lib/services/navigation/navigation_session_service....

704 lines
23 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:intl/intl.dart';
import '../location_service.dart';
import '../map/map_provider_interface.dart';
import '../routes/route_models.dart';
import '../routes/route_provider.dart';
import '../routes/route_providers.dart';
/// Navigation session status lifecycle.
enum NavigationStatus {
idle,
navigating,
paused,
rerouting,
offRoute,
arrived,
}
/// State of an active turn-by-turn navigation session.
@immutable
class NavigationSessionState {
final NavigationStatus status;
final MotorSignalRoute? route;
final ResolvedPlace? destination;
final RouteCalculationResult? calculationResult;
final List<RouteManeuver> maneuvers;
final int currentManeuverIndex;
final double distanceToNextManeuverMeters;
final double remainingDistanceMeters;
final int remainingDurationSeconds;
final bool ttsEnabled;
final String? lastAnnouncedInstruction;
final RoutingOptions routingOptions;
final DateTime? startedAt;
const NavigationSessionState({
this.status = NavigationStatus.idle,
this.route,
this.destination,
this.calculationResult,
this.maneuvers = const [],
this.currentManeuverIndex = 0,
this.distanceToNextManeuverMeters = 0.0,
this.remainingDistanceMeters = 0.0,
this.remainingDurationSeconds = 0,
this.ttsEnabled = true,
this.lastAnnouncedInstruction,
this.routingOptions = const RoutingOptions(),
this.startedAt,
});
bool get isNavigating =>
status == NavigationStatus.navigating || status == NavigationStatus.rerouting;
bool get isOffRoute => status == NavigationStatus.offRoute;
bool get hasActiveRoute => maneuvers.isNotEmpty;
RouteManeuver? get currentManeuver =>
currentManeuverIndex < maneuvers.length ? maneuvers[currentManeuverIndex] : null;
RouteManeuver? get nextManeuver =>
(currentManeuverIndex + 1) < maneuvers.length
? maneuvers[currentManeuverIndex + 1]
: null;
double get distanceToNextMiles =>
distanceToNextManeuverMeters * 0.000621371;
double get remainingDistanceMiles =>
remainingDistanceMeters * 0.000621371;
String get formattedDistanceToNext {
if (distanceToNextMiles < 0.1) {
final feet = (distanceToNextManeuverMeters * 3.28084).round();
return '$feet ft';
}
return '${distanceToNextMiles.toStringAsFixed(1)} mi';
}
String get formattedRemainingDistance {
return '${remainingDistanceMiles.toStringAsFixed(1)} mi';
}
String get formattedEta {
if (remainingDurationSeconds <= 0) return '--:--';
final arrival = DateTime.now().add(Duration(seconds: remainingDurationSeconds));
return DateFormat('h:mm a').format(arrival);
}
String get formattedDurationRemaining {
if (remainingDurationSeconds <= 0) return '0 min';
final totalSec = remainingDurationSeconds;
final hours = totalSec ~/ 3600;
final minutes = (totalSec % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes.clamp(1, 60)} min';
}
NavigationSessionState copyWith({
NavigationStatus? status,
MotorSignalRoute? route,
ResolvedPlace? destination,
RouteCalculationResult? calculationResult,
List<RouteManeuver>? maneuvers,
int? currentManeuverIndex,
double? distanceToNextManeuverMeters,
double? remainingDistanceMeters,
int? remainingDurationSeconds,
bool? ttsEnabled,
String? lastAnnouncedInstruction,
RoutingOptions? routingOptions,
DateTime? startedAt,
}) {
return NavigationSessionState(
status: status ?? this.status,
route: route ?? this.route,
destination: destination ?? this.destination,
calculationResult: calculationResult ?? this.calculationResult,
maneuvers: maneuvers ?? this.maneuvers,
currentManeuverIndex: currentManeuverIndex ?? this.currentManeuverIndex,
distanceToNextManeuverMeters:
distanceToNextManeuverMeters ?? this.distanceToNextManeuverMeters,
remainingDistanceMeters:
remainingDistanceMeters ?? this.remainingDistanceMeters,
remainingDurationSeconds:
remainingDurationSeconds ?? this.remainingDurationSeconds,
ttsEnabled: ttsEnabled ?? this.ttsEnabled,
lastAnnouncedInstruction:
lastAnnouncedInstruction ?? this.lastAnnouncedInstruction,
routingOptions: routingOptions ?? this.routingOptions,
startedAt: startedAt ?? this.startedAt,
);
}
}
/// Controller managing live motorcycle turn-by-turn navigation session.
class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
final FlutterTts? _tts;
final RouteProvider? routeProvider;
bool _ttsInitialized = false;
bool _isRecalculating = false;
DateTime? _lastRecalculationAttempt;
int _lastMatchedSegment = 0;
static const double defaultOffRouteThresholdMeters = 65.0;
NavigationSessionNotifier({
FlutterTts? tts,
this.routeProvider,
}) : _tts = tts ?? _createTtsSafe(),
super(const NavigationSessionState()) {
_initTts();
}
static FlutterTts? _createTtsSafe() {
try {
return FlutterTts();
} catch (_) {
return null;
}
}
bool get isNavigating => state.isNavigating;
Future<void> _initTts() async {
if (_tts == null) return;
try {
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(0.5); // Clear motorcycle cadence
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
try {
await _tts.setIosAudioCategory(
IosTextToSpeechAudioCategory.playback,
[
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
IosTextToSpeechAudioCategoryOptions.duckOthers,
],
IosTextToSpeechAudioMode.voicePrompt,
);
} catch (_) {}
_ttsInitialized = true;
} catch (_) {
_ttsInitialized = false;
}
}
/// Begins turn-by-turn guidance along a calculated route.
Future<void> startNavigation({
required RouteCalculationResult calculationResult,
ResolvedPlace? destination,
MotorSignalRoute? route,
RoutingOptions? options,
}) async {
final maneuvers = calculationResult.maneuvers;
if (maneuvers.isEmpty) {
return;
}
_lastMatchedSegment = 0;
final initialDist = calculationResult.estimatedDistanceMeters ??
calculationResult.straightLineDistanceMeters;
final initialDuration = calculationResult.estimatedDurationSeconds ??
(initialDist / 15.0).round(); // Default ~33 mph assumption if null
final firstManeuverDist = maneuvers.first.distanceMeters > 0
? maneuvers.first.distanceMeters
: (maneuvers.length > 1 ? maneuvers[1].distanceMeters : initialDist);
state = NavigationSessionState(
status: NavigationStatus.navigating,
route: route,
destination: destination,
calculationResult: calculationResult,
maneuvers: maneuvers,
currentManeuverIndex: 0,
distanceToNextManeuverMeters: firstManeuverDist,
remainingDistanceMeters: initialDist,
remainingDurationSeconds: initialDuration,
ttsEnabled: state.ttsEnabled,
routingOptions: options ?? const RoutingOptions(),
startedAt: DateTime.now(),
);
// Speak initial maneuver
final firstInstruction = maneuvers.first.instruction;
_speakInstruction(firstInstruction);
}
/// Halts and clears active navigation.
Future<void> stopNavigation() async {
if (_tts != null && _ttsInitialized) {
try {
await _tts.stop();
} catch (_) {}
}
state = const NavigationSessionState();
}
/// Pauses turn-by-turn tracking (e.g. during a rest stop).
void pauseNavigation() {
if (state.isNavigating) {
state = state.copyWith(status: NavigationStatus.paused);
}
}
/// Resumes paused navigation.
void resumeNavigation() {
if (state.status == NavigationStatus.paused) {
state = state.copyWith(status: NavigationStatus.navigating);
final current = state.currentManeuver;
if (current != null) {
_speakInstruction('Resuming route. ${current.instruction}');
}
}
}
/// Toggles spoken voice guidance.
Future<void> toggleTts() async {
final newEnabled = !state.ttsEnabled;
state = state.copyWith(ttsEnabled: newEnabled);
if (!newEnabled && _tts != null && _ttsInitialized) {
try {
await _tts.stop();
} catch (_) {}
}
}
/// Updates navigation progress against rider's current location and speed.
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
if (!state.isNavigating || state.maneuvers.isEmpty) return;
final points = state.calculationResult?.points ?? [];
if (points.isNotEmpty) {
final proj = _projectPointOntoPolyline(
riderPos,
points,
startSearchSegment: math.max(0, _lastMatchedSegment - 2),
);
// Off-route detection check against polyline
if (proj.distanceToRouteMeters > defaultOffRouteThresholdMeters) {
if (!_isRecalculating) {
final now = DateTime.now();
if (_lastRecalculationAttempt == null ||
now.difference(_lastRecalculationAttempt!) > const Duration(seconds: 8)) {
_lastRecalculationAttempt = now;
_triggerReroute(riderPos);
}
}
return;
}
_lastMatchedSegment = proj.segmentIndex;
// Check arrival at final destination stop
final lastManeuver = state.maneuvers.last;
final distToDest = _haversineMeters(riderPos, lastManeuver.location);
if (distToDest <= 25.0 &&
(proj.segmentIndex >= points.length - 2 || state.currentManeuverIndex >= state.maneuvers.length - 1)) {
_completeArrival();
return;
}
// Remaining distance strictly counts down along route polyline
final remainingMeters = _remainingDistanceAlongPolyline(
points,
proj.segmentIndex,
proj.projectedPoint,
);
// Maneuver advancement based on polyline progression
final offsets = _getManeuverOffsets(state.maneuvers, points);
int currIdx = state.currentManeuverIndex;
// If at step 0 (Depart), advance only once rider has genuinely departed (moving > 3.0 mph or past start point)
if (currIdx == 0 && state.maneuvers.first.type == ManeuverType.depart && state.maneuvers.length > 1) {
final distFromStart = _haversineMeters(riderPos, state.maneuvers.first.location);
if (speedMph > 3.0 || distFromStart > 35.0 || proj.segmentIndex > 0) {
currIdx = 1;
}
}
// Advance through completed subsequent maneuvers along the route (step 1+)
while (currIdx > 0 && currIdx < state.maneuvers.length - 1) {
final targetOffset = offsets[currIdx];
final targetLoc = state.maneuvers[currIdx].location;
final distToTarget = _haversineMeters(riderPos, targetLoc);
// Advance if rider reached within 25m of maneuver point or passed its polyline index
if (distToTarget <= 25.0 || (proj.segmentIndex >= targetOffset && targetOffset > 0)) {
currIdx++;
} else {
break;
}
}
// Announce updated maneuver if advanced
if (currIdx != state.currentManeuverIndex && currIdx < state.maneuvers.length) {
_speakInstruction(state.maneuvers[currIdx].instruction);
}
// Distance to next active maneuver along polyline
final targetOffset = offsets[currIdx];
final distToNext = _distanceToOffsetAlongPolyline(
points,
proj.segmentIndex,
proj.projectedPoint,
targetOffset,
targetLocation: state.maneuvers[currIdx].location,
);
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
state = state.copyWith(
currentManeuverIndex: currIdx,
distanceToNextManeuverMeters: distToNext,
remainingDistanceMeters: remainingMeters,
remainingDurationSeconds: remainingSec,
);
return;
}
// Fallback when polyline points are not available
final currIdx = state.currentManeuverIndex;
if (currIdx >= state.maneuvers.length) {
_completeArrival();
return;
}
final currentManeuver = state.maneuvers[currIdx];
final distToManeuver = _haversineMeters(riderPos, currentManeuver.location);
if (distToManeuver <= 25.0 && currIdx < state.maneuvers.length - 1) {
final nextIdx = currIdx + 1;
final nextManeuver = state.maneuvers[nextIdx];
final distToNext = _haversineMeters(riderPos, nextManeuver.location);
state = state.copyWith(
currentManeuverIndex: nextIdx,
distanceToNextManeuverMeters: distToNext,
);
_speakInstruction(nextManeuver.instruction);
return;
}
double remainingMeters = distToManeuver;
for (int i = currIdx + 1; i < state.maneuvers.length; i++) {
remainingMeters += state.maneuvers[i].distanceMeters;
}
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
state = state.copyWith(
distanceToNextManeuverMeters: distToManeuver,
remainingDistanceMeters: remainingMeters,
remainingDurationSeconds: remainingSec,
);
}
/// Triggers real route recalculation from rider's current position to destination.
Future<void> _triggerReroute(AppLatLng riderPos) async {
if (_isRecalculating) return;
_isRecalculating = true;
state = state.copyWith(status: NavigationStatus.rerouting);
_speakInstruction('Rerouting. Recalculating route.');
try {
final destStop = state.route?.stops.isNotEmpty == true
? state.route!.stops.last
: (state.destination != null
? RouteStop(
id: 'dest',
order: 1,
displayName: state.destination!.displayName,
latitude: state.destination!.latitude,
longitude: state.destination!.longitude,
)
: null);
final provider = routeProvider;
if (destStop == null || provider == null) {
state = state.copyWith(status: NavigationStatus.offRoute);
_isRecalculating = false;
return;
}
final originStop = RouteStop(
id: 'reroute_origin',
order: 0,
displayName: 'Current Location',
latitude: riderPos.latitude,
longitude: riderPos.longitude,
);
final newResult = await provider.calculateRoute(
[originStop, destStop],
options: state.routingOptions,
);
if (newResult.maneuvers.isEmpty) {
state = state.copyWith(status: NavigationStatus.offRoute);
_isRecalculating = false;
return;
}
final initialDist = newResult.estimatedDistanceMeters ??
newResult.straightLineDistanceMeters;
final initialDuration = newResult.estimatedDurationSeconds ??
(initialDist / 15.0).round();
_lastMatchedSegment = 0;
state = state.copyWith(
status: NavigationStatus.navigating,
calculationResult: newResult,
maneuvers: newResult.maneuvers,
currentManeuverIndex: 0,
distanceToNextManeuverMeters: newResult.maneuvers.first.distanceMeters,
remainingDistanceMeters: initialDist,
remainingDurationSeconds: initialDuration,
);
_isRecalculating = false;
_speakInstruction(newResult.maneuvers.first.instruction);
} catch (_) {
state = state.copyWith(status: NavigationStatus.offRoute);
_isRecalculating = false;
}
}
void _completeArrival() {
state = state.copyWith(
status: NavigationStatus.arrived,
remainingDistanceMeters: 0.0,
remainingDurationSeconds: 0,
distanceToNextManeuverMeters: 0.0,
);
_speakInstruction('You have arrived at your destination.');
}
Future<void> _speakInstruction(String instruction) async {
if (!state.ttsEnabled || _tts == null || !_ttsInitialized) return;
if (state.lastAnnouncedInstruction == instruction) return;
state = state.copyWith(lastAnnouncedInstruction: instruction);
try {
await _tts.speak(instruction, focus: true);
} catch (_) {}
}
/// Projects a GPS point onto the route polyline with segment index tracking.
static _PolylineProjection _projectPointOntoPolyline(
AppLatLng point,
List<AppLatLng> polyline, {
int startSearchSegment = 0,
}) {
if (polyline.isEmpty) {
return _PolylineProjection(
segmentIndex: 0,
projectedPoint: point,
distanceToRouteMeters: 0.0,
);
}
if (polyline.length == 1) {
return _PolylineProjection(
segmentIndex: 0,
projectedPoint: polyline.first,
distanceToRouteMeters: _haversineMeters(point, polyline.first),
);
}
int bestSegment = 0;
AppLatLng bestProj = polyline.first;
double minDistance = double.infinity;
final searchStart = startSearchSegment.clamp(0, polyline.length - 2);
for (int i = searchStart; i < polyline.length - 1; i++) {
final a = polyline[i];
final b = polyline[i + 1];
final proj = _projectPointOntoSegment(point, a, b);
final dist = _haversineMeters(point, proj);
if (dist < minDistance) {
minDistance = dist;
bestSegment = i;
bestProj = proj;
}
}
// If searching forward yielded an unacceptable match (> threshold)
// and search started midway, search earlier segments in case rider turned around
if (minDistance > defaultOffRouteThresholdMeters && searchStart > 0) {
for (int i = 0; i < searchStart; i++) {
final a = polyline[i];
final b = polyline[i + 1];
final proj = _projectPointOntoSegment(point, a, b);
final dist = _haversineMeters(point, proj);
if (dist < minDistance) {
minDistance = dist;
bestSegment = i;
bestProj = proj;
}
}
}
return _PolylineProjection(
segmentIndex: bestSegment,
projectedPoint: bestProj,
distanceToRouteMeters: minDistance,
);
}
static AppLatLng _projectPointOntoSegment(AppLatLng p, AppLatLng a, AppLatLng b) {
final dAB = _haversineMeters(a, b);
if (dAB == 0.0) return a;
final latRad = a.latitude * (math.pi / 180.0);
final cosLat = math.cos(latRad);
final dx = (b.longitude - a.longitude) * cosLat;
final dy = b.latitude - a.latitude;
final px = (p.longitude - a.longitude) * cosLat;
final py = p.latitude - a.latitude;
final lenSq = dx * dx + dy * dy;
if (lenSq == 0.0) return a;
final t = ((px * dx + py * dy) / lenSq).clamp(0.0, 1.0);
return AppLatLng(
a.latitude + t * (b.latitude - a.latitude),
a.longitude + t * (b.longitude - a.longitude),
);
}
/// Calculates strictly decreasing remaining distance along the polyline.
static double _remainingDistanceAlongPolyline(
List<AppLatLng> polyline,
int segmentIndex,
AppLatLng projectedPoint,
) {
if (polyline.isEmpty) return 0.0;
if (polyline.length == 1) return 0.0;
if (segmentIndex >= polyline.length - 1) {
return _haversineMeters(projectedPoint, polyline.last);
}
double remaining = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]);
for (int i = segmentIndex + 1; i < polyline.length - 1; i++) {
remaining += _haversineMeters(polyline[i], polyline[i + 1]);
}
return remaining;
}
/// Calculates distance along polyline from projected point to a maneuver offset.
static double _distanceToOffsetAlongPolyline(
List<AppLatLng> polyline,
int segmentIndex,
AppLatLng projectedPoint,
int targetOffset, {
required AppLatLng targetLocation,
}) {
if (polyline.isEmpty) return _haversineMeters(projectedPoint, targetLocation);
if (segmentIndex >= targetOffset) {
return _haversineMeters(projectedPoint, targetLocation);
}
double dist = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]);
final limit = math.min(targetOffset, polyline.length - 1);
for (int i = segmentIndex + 1; i < limit; i++) {
dist += _haversineMeters(polyline[i], polyline[i + 1]);
}
return dist;
}
/// Resolves monotonically increasing polyline indices for each maneuver.
static List<int> _getManeuverOffsets(
List<RouteManeuver> maneuvers,
List<AppLatLng> points,
) {
final offsets = <int>[];
for (int i = 0; i < maneuvers.length; i++) {
final m = maneuvers[i];
if (m.geometryOffset != null &&
m.geometryOffset! >= 0 &&
m.geometryOffset! < points.length) {
offsets.add(m.geometryOffset!);
} else {
int bestIdx = 0;
double bestDist = double.infinity;
for (int j = 0; j < points.length; j++) {
final d = _haversineMeters(points[j], m.location);
if (d < bestDist) {
bestDist = d;
bestIdx = j;
}
}
offsets.add(bestIdx);
}
}
for (int i = 1; i < offsets.length; i++) {
if (offsets[i] < offsets[i - 1]) {
offsets[i] = offsets[i - 1];
}
}
return offsets;
}
static double _haversineMeters(AppLatLng p1, AppLatLng p2) {
const r = 6371000.0; // Earth radius in meters
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0);
final lat1 = p1.latitude * (math.pi / 180.0);
final lat2 = p2.latitude * (math.pi / 180.0);
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.sin(dLon / 2) *
math.sin(dLon / 2) *
math.cos(lat1) *
math.cos(lat2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return r * c;
}
}
class _PolylineProjection {
final int segmentIndex;
final AppLatLng projectedPoint;
final double distanceToRouteMeters;
const _PolylineProjection({
required this.segmentIndex,
required this.projectedPoint,
required this.distanceToRouteMeters,
});
}
/// Global shared navigation session provider across Map and Dashboard.
final navigationSessionProvider =
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(
(ref) {
final routeProvider = ref.watch(activeRouteCalculationProvider);
final notifier = NavigationSessionNotifier(routeProvider: routeProvider);
// Connect live location updates when navigating
ref.listen(locationStreamProvider, (_, next) {
final pos = next.value;
if (pos != null && notifier.isNavigating) {
notifier.updateLocation(
AppLatLng(pos.latitude, pos.longitude),
speedMph: pos.speed * 2.23694,
);
}
});
return notifier;
},
);