351 lines
11 KiB
Dart
351 lines
11 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';
|
|
|
|
/// Navigation session status lifecycle.
|
|
enum NavigationStatus {
|
|
idle,
|
|
navigating,
|
|
paused,
|
|
rerouting,
|
|
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;
|
|
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;
|
|
bool _ttsInitialized = false;
|
|
|
|
NavigationSessionNotifier({FlutterTts? tts})
|
|
: _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);
|
|
_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;
|
|
}
|
|
|
|
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.
|
|
void toggleTts() {
|
|
final newEnabled = !state.ttsEnabled;
|
|
state = state.copyWith(ttsEnabled: newEnabled);
|
|
if (!newEnabled && _tts != null && _ttsInitialized) {
|
|
try {
|
|
_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 currIdx = state.currentManeuverIndex;
|
|
if (currIdx >= state.maneuvers.length) {
|
|
_completeArrival();
|
|
return;
|
|
}
|
|
|
|
final currentManeuver = state.maneuvers[currIdx];
|
|
final distToManeuver = _haversineMeters(riderPos, currentManeuver.location);
|
|
|
|
// If within 25 meters of maneuver point, advance to next maneuver
|
|
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;
|
|
}
|
|
|
|
// Recompute remaining distance along maneuvers
|
|
double remainingMeters = distToManeuver;
|
|
for (int i = currIdx + 1; i < state.maneuvers.length; i++) {
|
|
remainingMeters += state.maneuvers[i].distanceMeters;
|
|
}
|
|
|
|
// Dynamic duration based on speed (or default 30 mph if stationary)
|
|
final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4;
|
|
final remainingSec = (remainingMeters / effectiveSpeedMps).round();
|
|
|
|
state = state.copyWith(
|
|
distanceToNextManeuverMeters: distToManeuver,
|
|
remainingDistanceMeters: remainingMeters,
|
|
remainingDurationSeconds: remainingSec,
|
|
);
|
|
}
|
|
|
|
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);
|
|
} catch (_) {}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Global shared navigation session provider across Map and Dashboard.
|
|
final navigationSessionProvider =
|
|
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(
|
|
(ref) {
|
|
final notifier = NavigationSessionNotifier();
|
|
// 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;
|
|
},
|
|
);
|