704 lines
23 KiB
Dart
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;
|
|
},
|
|
);
|