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

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;
},
);