1283 lines
45 KiB
Dart
1283 lines
45 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart' show rootBundle;
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../../../core/constants/api_constants.dart';
|
|
import '../../../core/constants/app_colors.dart';
|
|
import '../../../core/services/car_platform_bridge.dart';
|
|
import '../../../core/services/connectivity_service.dart';
|
|
import '../../../core/services/location_service.dart';
|
|
import '../../../core/services/pip_service.dart';
|
|
import '../../../core/services/telemetry_tracking_service.dart';
|
|
import '../../../core/services/tts_service.dart';
|
|
import '../../../core/services/vehicle_icon_generator.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../../../data/models/hazard_model.dart';
|
|
import '../../../data/models/route_model.dart';
|
|
import '../../../data/repositories/map_saas_repository.dart';
|
|
import 'navigation_state.dart';
|
|
|
|
class NavigationCubit extends Cubit<NavigationState> {
|
|
final MapSaasRepository repository;
|
|
final LocationService locationService;
|
|
final TtsService ttsService;
|
|
final ConnectivityService connectivityService;
|
|
|
|
IntaleqMapController? mapController;
|
|
StreamSubscription<Position>? _positionStreamSub;
|
|
StreamSubscription<bool>? _connectivitySub;
|
|
Timer? _searchDebounce;
|
|
|
|
static const double _offRouteThresholdM = 50.0; // 50m rerouting threshold
|
|
DateTime? _offRouteStartTime;
|
|
bool _isRerouting = false;
|
|
int _lastTraveledIndexInFullRoute = 0;
|
|
|
|
NavigationCubit({
|
|
required this.repository,
|
|
LocationService? locationService,
|
|
TtsService? ttsService,
|
|
ConnectivityService? connectivityService,
|
|
}) : locationService = locationService ?? LocationService.instance,
|
|
ttsService = ttsService ?? TtsService.instance,
|
|
connectivityService = connectivityService ?? ConnectivityService.instance,
|
|
super(const NavigationState()) {
|
|
_init();
|
|
}
|
|
|
|
// ── MOVEMENT INTERPOLATION ENGINE (30 FPS Smooth Vehicle Motion) ──
|
|
Timer? _movementInterpolationTimer;
|
|
LatLng? _currentDisplayPosition;
|
|
double _currentDisplayHeading = 0.0;
|
|
LatLng? _animStartPosition;
|
|
LatLng? _animTargetPosition;
|
|
double _animStartHeading = 0.0;
|
|
double _animTargetHeading = 0.0;
|
|
int _animCurrentStep = 0;
|
|
static const int _animTotalSteps = 25; // 25 steps * 40ms = 1000ms duration
|
|
static const Duration _animTickDuration = Duration(milliseconds: 40);
|
|
|
|
Future<void> _init() async {
|
|
print("🚀 [NavigationCubit] Initializing NavigationCubit...");
|
|
CarPlatformBridge.ensureInitialized();
|
|
await ttsService.init();
|
|
|
|
// Initialize 3-second telemetry tracking & 2-minute batch uploads
|
|
TelemetryTrackingService.instance.initialize();
|
|
TelemetryTrackingService.instance.checkPeriodicUpdates();
|
|
|
|
// Load saved vehicle & search preferences
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final savedColor = prefs.getInt('siro_vehicle_color') ?? 0xFF007AFF;
|
|
final savedStyle = prefs.getString('siro_vehicle_style') ?? 'car';
|
|
final savedScale = prefs.getDouble('siro_vehicle_scale') ?? 1.8;
|
|
final savedRecent = prefs.getStringList('siro_recent_searches') ?? [];
|
|
emit(state.copyWith(
|
|
selectedVehicleColor: savedColor,
|
|
selectedVehicleStyle: savedStyle,
|
|
vehicleScale: savedScale,
|
|
recentSearches: savedRecent,
|
|
));
|
|
} catch (e) {
|
|
print("⚠️ [NavigationCubit] SharedPreferences load error: $e");
|
|
}
|
|
|
|
// Check & listen to network connectivity
|
|
connectivityService.initialize();
|
|
final isOnline = await connectivityService.checkConnection();
|
|
print("🌐 [NavigationCubit] Network connectivity status: isOnline=$isOnline");
|
|
emit(state.copyWith(isOnline: isOnline));
|
|
_connectivitySub = connectivityService.onConnectivityChanged.listen((online) {
|
|
print("🌐 [NavigationCubit] Connectivity changed event: online=$online");
|
|
emit(state.copyWith(isOnline: online));
|
|
});
|
|
|
|
// Default to Amman if initial position is fetching
|
|
final defaultPos = const LatLng(ApiConstants.defaultLat, ApiConstants.defaultLng);
|
|
emit(state.copyWith(myLocation: defaultPos, altitude: 0.0));
|
|
|
|
final position = await locationService.getCurrentPosition();
|
|
if (position != null) {
|
|
final loc = LatLng(position.latitude, position.longitude);
|
|
final alt = (position.altitude.isNaN || position.altitude.isInfinite) ? 0.0 : position.altitude;
|
|
print("📍 [NavigationCubit] Initial GPS location acquired: lat=${loc.latitude.toStringAsFixed(6)}, lng=${loc.longitude.toStringAsFixed(6)}, alt=${alt.toStringAsFixed(1)}m, heading=${position.heading.toStringAsFixed(1)}°");
|
|
emit(state.copyWith(
|
|
myLocation: loc,
|
|
altitude: alt,
|
|
heading: position.heading,
|
|
speed: position.speed * 3.6,
|
|
));
|
|
_currentDisplayPosition = loc;
|
|
_currentDisplayHeading = position.heading;
|
|
_updateCarMarker(loc, position.heading);
|
|
} else {
|
|
print("⚠️ [NavigationCubit] Initial GPS returned null, using default Amman center");
|
|
}
|
|
|
|
_startLocationUpdates();
|
|
}
|
|
|
|
bool _hasInitiallyCenteredCamera = false;
|
|
bool _isMapStyleLoaded = false;
|
|
bool get isMapStyleLoaded => _isMapStyleLoaded;
|
|
int? _hasAnnouncedEarlyStepIndex;
|
|
|
|
void onMapCreated(IntaleqMapController controller) {
|
|
print("🗺️ [NavigationCubit] onMapCreated: Native map view created, controller attached.");
|
|
mapController = controller;
|
|
emit(state.copyWith(status: NavigationStatus.mapReady));
|
|
}
|
|
|
|
Future<void> _animateCameraToCurrentPosition() async {
|
|
LatLng? target = state.myLocation;
|
|
double heading = state.heading;
|
|
print("🎥 [NavigationCubit] _animateCameraToCurrentPosition (target=$target, styleLoaded=$_isMapStyleLoaded)");
|
|
|
|
if (target == null || (target.latitude == ApiConstants.defaultLat && target.longitude == ApiConstants.defaultLng)) {
|
|
final pos = await locationService.getCurrentPosition();
|
|
if (pos != null) {
|
|
final loc = LatLng(pos.latitude, pos.longitude);
|
|
final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude;
|
|
target = loc;
|
|
heading = pos.heading;
|
|
emit(state.copyWith(
|
|
myLocation: loc,
|
|
altitude: alt,
|
|
heading: pos.heading,
|
|
speed: pos.speed * 3.6,
|
|
));
|
|
_currentDisplayPosition = loc;
|
|
_currentDisplayHeading = pos.heading;
|
|
_updateCarMarker(loc, pos.heading);
|
|
}
|
|
}
|
|
|
|
if (target != null && mapController != null && _isMapStyleLoaded) {
|
|
mapController!.animateCamera(
|
|
CameraUpdate.newCameraPosition(
|
|
CameraPosition(
|
|
target: target,
|
|
zoom: 16.5,
|
|
bearing: heading,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _registerCurrentVehicleIcon() async {
|
|
if (mapController == null) return;
|
|
try {
|
|
final bytes = await VehicleIconGenerator.generateVehicleIconBytes(
|
|
styleId: state.selectedVehicleStyle,
|
|
primaryColor: Color(state.selectedVehicleColor),
|
|
);
|
|
await mapController!.addImage('current_vehicle_icon', bytes);
|
|
print("🚗 [NavigationCubit] Registered dynamic current_vehicle_icon (style=${state.selectedVehicleStyle}, color=0x${state.selectedVehicleColor.toRadixString(16)}, scale=${state.vehicleScale})");
|
|
} catch (e) {
|
|
print("⚠️ [NavigationCubit] Error generating vehicle icon: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _loadCustomIcons() async {
|
|
if (mapController == null) return;
|
|
|
|
// 1. Dynamic vehicle icon (with user-selected color & model)
|
|
await _registerCurrentVehicleIcon();
|
|
|
|
// 2. Fallback static car icon
|
|
try {
|
|
final carBytes = await rootBundle.load('assets/images/car.png');
|
|
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
|
|
} catch (_) {}
|
|
|
|
// 3. Start & Destination pins
|
|
try {
|
|
final startBytes = await rootBundle.load('assets/images/A.png');
|
|
await mapController!.addImage('start_icon', startBytes.buffer.asUint8List());
|
|
await mapController!.addImage('asset_assets_images_A_png', startBytes.buffer.asUint8List());
|
|
} catch (_) {}
|
|
|
|
try {
|
|
final destBytes = await rootBundle.load('assets/images/b.png');
|
|
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
|
|
await mapController!.addImage('asset_assets_images_b_png', destBytes.buffer.asUint8List());
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> onStyleLoaded() async {
|
|
print("🎨 [NavigationCubit] onStyleLoaded: Map style rendered successfully! Registering custom icons & centering camera.");
|
|
_isMapStyleLoaded = true;
|
|
await _loadCustomIcons();
|
|
if (state.myLocation != null) {
|
|
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
|
|
}
|
|
_animateCameraToCurrentPosition();
|
|
}
|
|
|
|
// ── VEHICLE CUSTOMIZATION HANDLERS ──────────────────────────
|
|
|
|
Future<void> setVehicleColor(int colorValue) async {
|
|
emit(state.copyWith(selectedVehicleColor: colorValue));
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setInt('siro_vehicle_color', colorValue);
|
|
} catch (_) {}
|
|
await _registerCurrentVehicleIcon();
|
|
if (state.myLocation != null) {
|
|
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
|
|
}
|
|
}
|
|
|
|
Future<void> setVehicleStyle(String styleId) async {
|
|
emit(state.copyWith(selectedVehicleStyle: styleId));
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('siro_vehicle_style', styleId);
|
|
} catch (_) {}
|
|
await _registerCurrentVehicleIcon();
|
|
if (state.myLocation != null) {
|
|
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
|
|
}
|
|
}
|
|
|
|
Future<void> setVehicleScale(double scale) async {
|
|
emit(state.copyWith(vehicleScale: scale));
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setDouble('siro_vehicle_scale', scale);
|
|
} catch (_) {}
|
|
if (state.myLocation != null) {
|
|
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
|
|
}
|
|
}
|
|
|
|
// ── LOCATION STREAM & SMOOTH INTERPOLATION ────────────────────
|
|
|
|
void _startLocationUpdates() {
|
|
_positionStreamSub?.cancel();
|
|
_positionStreamSub = locationService.getPositionStream().listen((pos) {
|
|
final newLoc = LatLng(pos.latitude, pos.longitude);
|
|
final speedKmH = pos.speed * 3.6;
|
|
final heading = pos.heading;
|
|
final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude;
|
|
|
|
_onNewLocationFix(newLoc, heading, speedKmH, alt);
|
|
});
|
|
}
|
|
|
|
void _onNewLocationFix(LatLng newLoc, double heading, double speedKmH, double alt) {
|
|
emit(state.copyWith(
|
|
myLocation: newLoc,
|
|
altitude: alt,
|
|
heading: heading,
|
|
speed: speedKmH,
|
|
));
|
|
|
|
// Smooth movement interpolation for the vehicle marker and 3D camera
|
|
if (_currentDisplayPosition == null) {
|
|
_currentDisplayPosition = newLoc;
|
|
_currentDisplayHeading = heading;
|
|
_updateCarMarker(newLoc, heading);
|
|
} else {
|
|
_startMovementInterpolation(newLoc, heading);
|
|
}
|
|
|
|
// Continuous 3-second telemetry sampling with stationary filtering and 2-minute batching
|
|
TelemetryTrackingService.instance.recordPosition(
|
|
latitude: newLoc.latitude,
|
|
longitude: newLoc.longitude,
|
|
speedKmH: speedKmH,
|
|
heading: heading,
|
|
elevation: alt,
|
|
remainingDistance: state.remainingDistance,
|
|
);
|
|
|
|
// Proactively move camera on first acquired GPS lock (only when style is loaded)
|
|
if (!_hasInitiallyCenteredCamera && mapController != null && _isMapStyleLoaded) {
|
|
_hasInitiallyCenteredCamera = true;
|
|
mapController!.animateCamera(
|
|
CameraUpdate.newCameraPosition(
|
|
CameraPosition(target: newLoc, zoom: 16.5, bearing: heading),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state.isNavigating) {
|
|
_processActiveNavigationTick(newLoc, speedKmH, heading);
|
|
}
|
|
}
|
|
|
|
void _startMovementInterpolation(LatLng targetPos, double targetHeading) {
|
|
_movementInterpolationTimer?.cancel();
|
|
|
|
_animStartPosition = _currentDisplayPosition ?? targetPos;
|
|
_animStartHeading = _currentDisplayHeading;
|
|
_animTargetPosition = targetPos;
|
|
_animTargetHeading = targetHeading;
|
|
_animCurrentStep = 0;
|
|
|
|
_movementInterpolationTimer = Timer.periodic(_animTickDuration, (timer) {
|
|
_animCurrentStep++;
|
|
final double t = (_animCurrentStep / _animTotalSteps).clamp(0.0, 1.0);
|
|
|
|
// Smooth ease-out curve for natural vehicle movement
|
|
final double curvedT = 1.0 - (1.0 - t) * (1.0 - t);
|
|
|
|
final interpPos = _lerpLatLng(_animStartPosition!, _animTargetPosition!, curvedT);
|
|
final interpHeading = _lerpAngle(_animStartHeading, _animTargetHeading, curvedT);
|
|
|
|
_currentDisplayPosition = interpPos;
|
|
_currentDisplayHeading = interpHeading;
|
|
|
|
_updateCarMarker(interpPos, interpHeading);
|
|
|
|
// Smoothly update Google Maps style 3D camera periodically (~every 200ms) or on completion
|
|
if (state.isCameraLocked && mapController != null && _isMapStyleLoaded && state.isNavigating) {
|
|
if (_animCurrentStep % 5 == 0 || _animCurrentStep == _animTotalSteps) {
|
|
_updateNavigationCamera(interpPos, interpHeading, state.speed, state.distanceToNextStep);
|
|
}
|
|
}
|
|
|
|
if (_animCurrentStep >= _animTotalSteps) {
|
|
timer.cancel();
|
|
_movementInterpolationTimer = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── GOOGLE MAPS NAVIGATION PERSPECTIVE & DYNAMIC ZOOM ─────────
|
|
|
|
void _updateNavigationCamera(LatLng pos, double heading, double speedKmH, double distanceToStep) {
|
|
if (mapController == null || !_isMapStyleLoaded || !state.isCameraLocked) return;
|
|
|
|
// 1. Dynamic speed-adaptive zoom & lookahead calculation
|
|
double targetZoom;
|
|
double lookAheadMeters;
|
|
const double targetTilt = 45.0; // Fixed 45-degree angle as requested by user
|
|
|
|
if (distanceToStep > 0 && distanceToStep < 100.0) {
|
|
// Approaching turn / maneuver: camera descends down close to show intersection
|
|
targetZoom = 18.2;
|
|
lookAheadMeters = 35.0;
|
|
} else if (speedKmH > 70.0) {
|
|
// High speed (highway): opens up distance ahead ("تكبر تفتح مسافة أكثر")
|
|
final speedFactor = ((speedKmH - 70.0) / 50.0).clamp(0.0, 1.0);
|
|
targetZoom = 16.5 - (0.7 * speedFactor); // 16.5 -> 15.8
|
|
lookAheadMeters = 70.0 + (30.0 * speedFactor); // 70m -> 100m
|
|
} else if (speedKmH > 20.0) {
|
|
// Moderate city speed
|
|
final speedFactor = ((speedKmH - 20.0) / 50.0).clamp(0.0, 1.0);
|
|
targetZoom = 17.8 - (0.8 * speedFactor); // 17.8 -> 17.0
|
|
lookAheadMeters = 40.0 + (25.0 * speedFactor); // 40m -> 65m
|
|
} else {
|
|
// Stopped / very slow (< 20 km/h)
|
|
targetZoom = 18.0;
|
|
lookAheadMeters = 35.0;
|
|
}
|
|
|
|
// 2. Heading stabilization (lock to route if stopped/creeping)
|
|
double effectiveBearing = heading;
|
|
if (speedKmH < 4.0 && state.currentRoute != null) {
|
|
final coords = state.currentRoute!.coordinates;
|
|
if (_lastTraveledIndexInFullRoute + 1 < coords.length) {
|
|
effectiveBearing = _calculateBearing(
|
|
coords[_lastTraveledIndexInFullRoute],
|
|
coords[_lastTraveledIndexInFullRoute + 1],
|
|
);
|
|
}
|
|
}
|
|
|
|
// 3. Shift camera target ahead along heading vector
|
|
// This positions the car in the bottom ~28% of the viewport with 45° tilt!
|
|
final cameraTarget = _computeOffset(pos, lookAheadMeters, effectiveBearing);
|
|
|
|
mapController!.animateCamera(
|
|
CameraUpdate.newCameraPosition(
|
|
CameraPosition(
|
|
target: cameraTarget,
|
|
zoom: targetZoom,
|
|
tilt: targetTilt,
|
|
bearing: effectiveBearing,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
LatLng _computeOffset(LatLng from, double distanceMeters, double bearingDegrees) {
|
|
const double earthRadius = 6378137.0; // WGS-84 earth radius in meters
|
|
final double dByR = distanceMeters / earthRadius;
|
|
final double latRad = from.latitude * (pi / 180.0);
|
|
final double lonRad = from.longitude * (pi / 180.0);
|
|
final double bearingRad = bearingDegrees * (pi / 180.0);
|
|
|
|
final double targetLatRad = asin(
|
|
sin(latRad) * cos(dByR) + cos(latRad) * sin(dByR) * cos(bearingRad),
|
|
);
|
|
final double targetLonRad = lonRad + atan2(
|
|
sin(bearingRad) * sin(dByR) * cos(latRad),
|
|
cos(dByR) - sin(latRad) * sin(targetLatRad),
|
|
);
|
|
|
|
return LatLng(targetLatRad * (180.0 / pi), targetLonRad * (180.0 / pi));
|
|
}
|
|
|
|
double _lerpAngle(double from, double to, double t) {
|
|
final double diff = ((to - from + 540.0) % 360.0) - 180.0;
|
|
return (from + diff * t) % 360.0;
|
|
}
|
|
|
|
LatLng _lerpLatLng(LatLng a, LatLng b, double t) {
|
|
return LatLng(
|
|
a.latitude + (b.latitude - a.latitude) * t,
|
|
a.longitude + (b.longitude - a.longitude) * t,
|
|
);
|
|
}
|
|
|
|
Future<void> _updateCarMarker(LatLng position, double bearing) async {
|
|
if (mapController == null || !_isMapStyleLoaded) return;
|
|
try {
|
|
await mapController!.setUserMarker(Marker(
|
|
markerId: const MarkerId('current_user_car'),
|
|
position: position,
|
|
rotation: bearing,
|
|
anchor: const Offset(0.5, 0.5),
|
|
flat: true,
|
|
icon: InlqBitmap.fromStyleImage('current_vehicle_icon', size: state.vehicleScale),
|
|
zIndex: 100,
|
|
));
|
|
} catch (e) {
|
|
print("⚠️ [NavigationCubit] _updateCarMarker error: $e");
|
|
}
|
|
}
|
|
|
|
void setCameraLocked(bool locked) {
|
|
emit(state.copyWith(isCameraLocked: locked));
|
|
}
|
|
|
|
void relockCameraToUser() {
|
|
print("🎯 [NavigationCubit] relockCameraToUser requested (styleLoaded=$_isMapStyleLoaded, loc=${state.myLocation})");
|
|
emit(state.copyWith(isCameraLocked: true));
|
|
if (state.myLocation != null && mapController != null && _isMapStyleLoaded) {
|
|
if (state.isNavigating) {
|
|
_updateNavigationCamera(
|
|
_currentDisplayPosition ?? state.myLocation!,
|
|
_currentDisplayHeading,
|
|
state.speed,
|
|
state.distanceToNextStep,
|
|
);
|
|
} else {
|
|
mapController!.animateCamera(
|
|
CameraUpdate.newCameraPosition(
|
|
CameraPosition(
|
|
target: state.myLocation!,
|
|
zoom: 16.5,
|
|
tilt: 0.0,
|
|
bearing: state.heading,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void setMapTheme(MapThemeType theme) {
|
|
print("🎨 [NavigationCubit] Changing map theme to: $theme (resetting _isMapStyleLoaded=false)");
|
|
_isMapStyleLoaded = false;
|
|
emit(state.copyWith(mapTheme: theme));
|
|
}
|
|
|
|
void toggleMute() {
|
|
ttsService.toggleMute();
|
|
emit(state.copyWith(isMuted: ttsService.isMuted));
|
|
}
|
|
|
|
void toggleLocationPicker() {
|
|
emit(state.copyWith(
|
|
isSelectingLocationOnMap: !state.isSelectingLocationOnMap,
|
|
));
|
|
}
|
|
|
|
// ── SEARCH & DESTINATION SELECTION ──────────────────────────
|
|
|
|
void onSearchChanged(String query) {
|
|
_searchDebounce?.cancel();
|
|
final trimmed = query.trim();
|
|
if (trimmed.length < 2) {
|
|
emit(state.copyWith(searchResults: [], isSearching: false));
|
|
return;
|
|
}
|
|
emit(state.copyWith(isSearching: true));
|
|
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
|
|
final results = await repository.searchPlaces(
|
|
query: trimmed,
|
|
userLocation: state.myLocation,
|
|
);
|
|
emit(state.copyWith(searchResults: results, isSearching: false));
|
|
});
|
|
}
|
|
|
|
Future<void> searchImmediately(String query) async {
|
|
_searchDebounce?.cancel();
|
|
final trimmed = query.trim();
|
|
if (trimmed.isEmpty) {
|
|
emit(state.copyWith(searchResults: [], isSearching: false));
|
|
return;
|
|
}
|
|
emit(state.copyWith(isSearching: true));
|
|
await saveRecentSearch(trimmed);
|
|
final results = await repository.searchPlaces(
|
|
query: trimmed,
|
|
userLocation: state.myLocation,
|
|
);
|
|
emit(state.copyWith(searchResults: results, isSearching: false));
|
|
}
|
|
|
|
Future<void> saveRecentSearch(String query) async {
|
|
final trimmed = query.trim();
|
|
if (trimmed.length < 2) return;
|
|
final updated = List<String>.from(state.recentSearches)
|
|
..remove(trimmed)
|
|
..insert(0, trimmed);
|
|
if (updated.length > 8) updated.removeLast();
|
|
emit(state.copyWith(recentSearches: updated));
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setStringList('siro_recent_searches', updated);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> clearRecentSearches() async {
|
|
emit(state.copyWith(recentSearches: []));
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove('siro_recent_searches');
|
|
} catch (_) {}
|
|
}
|
|
|
|
void clearSearch() {
|
|
_searchDebounce?.cancel();
|
|
emit(state.copyWith(searchResults: [], isSearching: false));
|
|
}
|
|
|
|
// ── STEP BOUNDING OVERVIEW ──────────────────────────────────
|
|
|
|
void overviewStepBounding(int stepIndex) {
|
|
if (state.currentRoute == null || stepIndex < 0 || stepIndex >= state.routeSteps.length) return;
|
|
final step = state.routeSteps[stepIndex];
|
|
final interval = step['interval'];
|
|
final coords = state.currentRoute!.coordinates;
|
|
|
|
if (interval is List && interval.length >= 2) {
|
|
final startIdx = (interval[0] as num).toInt().clamp(0, coords.length - 1);
|
|
final endIdx = (interval[1] as num).toInt().clamp(0, coords.length - 1);
|
|
|
|
if (startIdx <= endIdx) {
|
|
final stepCoords = coords.sublist(startIdx, endIdx + 1);
|
|
if (stepCoords.isNotEmpty) {
|
|
_fitRouteInView(stepCoords);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
final stepLat = (step['lat'] as num?)?.toDouble() ?? 0.0;
|
|
final stepLng = (step['lng'] as num?)?.toDouble() ?? 0.0;
|
|
if (stepLat != 0.0 && stepLng != 0.0 && mapController != null && _isMapStyleLoaded) {
|
|
mapController!.animateCamera(
|
|
CameraUpdate.newCameraPosition(
|
|
CameraPosition(target: LatLng(stepLat, stepLng), zoom: 17.5, tilt: 45.0),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> calculateRouteTo(
|
|
LatLng destination, {
|
|
String title = 'وجهة مختارة',
|
|
String originTitle = 'موقعي الحالي',
|
|
}) async {
|
|
print("🛣️ [NavigationCubit] calculateRouteTo: origin=$originTitle, target=$destination, title=$title, myLocation=${state.myLocation}");
|
|
if (state.myLocation == null) {
|
|
print("⚠️ [NavigationCubit] Cannot calculate route: current GPS location is null!");
|
|
return;
|
|
}
|
|
emit(state.copyWith(
|
|
status: NavigationStatus.loading,
|
|
destination: destination,
|
|
destinationTitle: title,
|
|
originTitle: originTitle,
|
|
searchResults: [],
|
|
));
|
|
|
|
try {
|
|
print("🌐 [NavigationCubit] Requesting route from repository...");
|
|
final routes = await repository.getRoute(
|
|
origin: state.myLocation!,
|
|
destination: destination,
|
|
);
|
|
|
|
if (routes.isEmpty) {
|
|
print("❌ [NavigationCubit] Repository returned 0 routes!");
|
|
emit(state.copyWith(
|
|
status: NavigationStatus.error,
|
|
errorMessage: 'تعذر حساب المسار إلى الوجهة المحددة.',
|
|
));
|
|
return;
|
|
}
|
|
|
|
final primaryRoute = routes.first;
|
|
print("✅ [NavigationCubit] Route calculated successfully: ${routes.length} routes found, primary: dist=${primaryRoute.formattedDistance}, dur=${primaryRoute.formattedDuration}, coordsCount=${primaryRoute.coordinates.length}");
|
|
final polylines = _createPolylines(routes, 0);
|
|
|
|
// Origin Pin A
|
|
final startMarker = Marker(
|
|
markerId: const MarkerId('origin_pin'),
|
|
position: state.myLocation!,
|
|
icon: InlqBitmap.fromStyleImage('start_icon'),
|
|
anchor: const Offset(0.5, 1.0),
|
|
infoWindow: InfoWindow(title: originTitle, snippet: 'start'),
|
|
zIndex: 90,
|
|
);
|
|
|
|
// Destination Pin B
|
|
final destMarker = Marker(
|
|
markerId: const MarkerId('dest_pin'),
|
|
position: destination,
|
|
icon: InlqBitmap.fromStyleImage('dest_icon'),
|
|
anchor: const Offset(0.5, 1.0),
|
|
infoWindow: InfoWindow(title: title, snippet: 'end'),
|
|
zIndex: 90,
|
|
);
|
|
|
|
final updatedMarkers = Set<Marker>.from(state.markers)
|
|
..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin')
|
|
..add(startMarker)
|
|
..add(destMarker);
|
|
|
|
emit(state.copyWith(
|
|
status: NavigationStatus.routePreview,
|
|
routes: routes,
|
|
selectedRouteIndex: 0,
|
|
destination: destination,
|
|
destinationTitle: title,
|
|
originTitle: originTitle,
|
|
routeSteps: primaryRoute.steps,
|
|
remainingDistance: primaryRoute.distanceM,
|
|
remainingDuration: primaryRoute.durationS,
|
|
polylines: polylines,
|
|
markers: updatedMarkers,
|
|
arrivalTime: _calculateArrivalTime(primaryRoute.durationS),
|
|
));
|
|
|
|
if (mapController != null && _isMapStyleLoaded) {
|
|
await mapController!.addMarker(startMarker);
|
|
await mapController!.addMarker(destMarker);
|
|
print("📍 [NavigationCubit] Directly added startMarker & destMarker to native map engine.");
|
|
}
|
|
|
|
_fitRouteInView(primaryRoute.coordinates);
|
|
} catch (e) {
|
|
print("❌ [NavigationCubit] Error calculating route: $e");
|
|
emit(state.copyWith(
|
|
status: NavigationStatus.error,
|
|
errorMessage: 'حدث خطأ أثناء استدعاء خدمة التوجيه.',
|
|
));
|
|
}
|
|
}
|
|
|
|
void selectRoute(int index) {
|
|
if (index < 0 || index >= state.routes.length) return;
|
|
final route = state.routes[index];
|
|
final polylines = _createPolylines(state.routes, index);
|
|
|
|
emit(state.copyWith(
|
|
selectedRouteIndex: index,
|
|
routeSteps: route.steps,
|
|
remainingDistance: route.distanceM,
|
|
remainingDuration: route.durationS,
|
|
polylines: polylines,
|
|
arrivalTime: _calculateArrivalTime(route.durationS),
|
|
));
|
|
|
|
_fitRouteInView(route.coordinates);
|
|
}
|
|
|
|
// ── TURN-BY-TURN NAVIGATION LIFECYCLE ────────────────────────
|
|
|
|
void startNavigation() {
|
|
print("🧭 [NavigationCubit] startNavigation triggered!");
|
|
if (state.currentRoute == null) {
|
|
print("⚠️ [NavigationCubit] startNavigation aborted: currentRoute is null");
|
|
return;
|
|
}
|
|
final route = state.currentRoute!;
|
|
final steps = route.steps;
|
|
print("🧭 [NavigationCubit] Starting navigation along route: ${route.formattedDistance}, ETA ${route.formattedDuration}, ${steps.length} steps");
|
|
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
_offRouteStartTime = null;
|
|
|
|
final firstInstruction = steps.isNotEmpty
|
|
? (steps[0]['text']?.toString() ?? 'انطلق نحو الوجهة')
|
|
: 'انطلق نحو الوجهة';
|
|
|
|
final nextInst = steps.length > 1
|
|
? (steps[1]['text']?.toString() ?? '')
|
|
: '';
|
|
|
|
final initialModifier = steps.isNotEmpty ? (steps[0]['sign'] ?? steps[0]['modifier'] ?? 0) : 0;
|
|
|
|
emit(state.copyWith(
|
|
status: NavigationStatus.navigating,
|
|
currentStepIndex: 0,
|
|
currentInstruction: firstInstruction,
|
|
nextInstruction: nextInst,
|
|
currentManeuverModifier: initialModifier,
|
|
isCameraLocked: true,
|
|
polylines: _createPolylines(state.routes, state.selectedRouteIndex, traveledIndex: 0),
|
|
));
|
|
|
|
ttsService.speak(firstInstruction);
|
|
|
|
// Immediate 3D Heading-Up camera orientation with 45° tilt and lookahead offset
|
|
if (state.myLocation != null && mapController != null && _isMapStyleLoaded) {
|
|
double bearing = state.heading;
|
|
if (bearing == 0.0 && route.coordinates.length > 1) {
|
|
bearing = _calculateBearing(route.coordinates[0], route.coordinates[1]);
|
|
}
|
|
_currentDisplayPosition = state.myLocation;
|
|
_currentDisplayHeading = bearing;
|
|
_updateNavigationCamera(state.myLocation!, bearing, state.speed, 100.0);
|
|
}
|
|
|
|
CarPlatformBridge.updateNavState(
|
|
lat: state.myLocation!.latitude,
|
|
lng: state.myLocation!.longitude,
|
|
bearing: state.heading,
|
|
speed: state.speed,
|
|
instruction: firstInstruction,
|
|
distanceToStep: 100,
|
|
totalDistance: route.distanceM,
|
|
eta: route.durationS,
|
|
maneuver: initialModifier,
|
|
isNavigating: true,
|
|
);
|
|
PipService.instance.setNavigating(true);
|
|
}
|
|
|
|
void stopNavigation() {
|
|
print("🛑 [NavigationCubit] stopNavigation triggered.");
|
|
TelemetryTrackingService.instance.flush(repository: repository);
|
|
_movementInterpolationTimer?.cancel();
|
|
_movementInterpolationTimer = null;
|
|
ttsService.stop();
|
|
CarPlatformBridge.stopNavigation();
|
|
PipService.instance.setNavigating(false);
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
_offRouteStartTime = null;
|
|
_hasAnnouncedEarlyStepIndex = null;
|
|
|
|
final remainingMarkers = Set<Marker>.from(state.markers)
|
|
..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin');
|
|
|
|
if (mapController != null && _isMapStyleLoaded) {
|
|
mapController!.removeMarker(const MarkerId('origin_pin'));
|
|
mapController!.removeMarker(const MarkerId('dest_pin'));
|
|
}
|
|
|
|
emit(state.copyWith(
|
|
status: NavigationStatus.mapReady,
|
|
routes: [],
|
|
routeSteps: [],
|
|
polylines: {},
|
|
markers: remainingMarkers,
|
|
destination: null,
|
|
destinationTitle: '',
|
|
originTitle: 'موقعي الحالي',
|
|
currentInstruction: '',
|
|
nextInstruction: '',
|
|
isCameraLocked: true,
|
|
));
|
|
if (state.myLocation != null && mapController != null && _isMapStyleLoaded) {
|
|
mapController!.animateCamera(
|
|
CameraUpdate.newCameraPosition(
|
|
CameraPosition(target: state.myLocation!, zoom: 16.5, tilt: 0),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void simulateLocationTick(LatLng pos, {double speed = 60.0, double heading = 0.0, double altitude = 0.0}) {
|
|
_onNewLocationFix(pos, heading, speed, altitude);
|
|
}
|
|
|
|
void _processActiveNavigationTick(LatLng pos, double speedKmH, double heading) {
|
|
if (state.currentRoute == null || state.routeSteps.isEmpty) return;
|
|
|
|
final route = state.currentRoute!;
|
|
final coords = route.coordinates;
|
|
|
|
// 1. Check destination arrival
|
|
final dest = state.destination;
|
|
if (dest != null) {
|
|
final distToFinal = locationService.calculateDistance(pos, dest);
|
|
if (distToFinal < 25.0) {
|
|
ttsService.speak('لقد وصلت إلى وجهتك.');
|
|
emit(state.copyWith(status: NavigationStatus.arrived));
|
|
CarPlatformBridge.stopNavigation();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 2. Turn-by-Turn step progression
|
|
final steps = state.routeSteps;
|
|
int stepIdx = state.currentStepIndex;
|
|
|
|
if (stepIdx < steps.length) {
|
|
final step = steps[stepIdx];
|
|
LatLng? stepTarget;
|
|
|
|
final stepLat = (step['lat'] as num?)?.toDouble() ?? 0.0;
|
|
final stepLng = (step['lng'] as num?)?.toDouble() ?? 0.0;
|
|
|
|
if (stepLat != 0.0 && stepLng != 0.0) {
|
|
stepTarget = LatLng(stepLat, stepLng);
|
|
} else {
|
|
final interval = step['interval'];
|
|
if (interval is List && interval.length >= 2) {
|
|
final endIdx = (interval[1] as num).toInt();
|
|
if (endIdx >= 0 && endIdx < coords.length) {
|
|
stepTarget = coords[endIdx];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (stepTarget != null) {
|
|
final distToStep = locationService.calculateDistance(pos, stepTarget);
|
|
emit(state.copyWith(distanceToNextStep: distToStep));
|
|
|
|
// Advance voice announcement when approaching turn (150m - 200m)
|
|
if (distToStep <= 180.0 && distToStep > 50.0 && _hasAnnouncedEarlyStepIndex != stepIdx) {
|
|
_hasAnnouncedEarlyStepIndex = stepIdx;
|
|
final roundDist = ((distToStep / 50).round() * 50).clamp(50, 200);
|
|
final text = step['text']?.toString() ?? '';
|
|
if (text.isNotEmpty) {
|
|
ttsService.speak('بعد $roundDist متراً، $text');
|
|
}
|
|
}
|
|
|
|
final stepEndIdx = (step['interval'] is List && (step['interval'] as List).length >= 2)
|
|
? ((step['interval'] as List)[1] as num).toInt()
|
|
: -1;
|
|
final hasPassedStep = stepEndIdx > 0 && _lastTraveledIndexInFullRoute >= stepEndIdx;
|
|
|
|
if ((distToStep < 35.0 || hasPassedStep) && stepIdx + 1 < steps.length) {
|
|
stepIdx++;
|
|
_hasAnnouncedEarlyStepIndex = null;
|
|
final nextStep = steps[stepIdx];
|
|
final text = nextStep['text']?.toString() ?? '';
|
|
final upcomingText = stepIdx + 1 < steps.length ? (steps[stepIdx + 1]['text']?.toString() ?? '') : '';
|
|
final modifier = nextStep['sign'] ?? nextStep['modifier'] ?? 0;
|
|
|
|
emit(state.copyWith(
|
|
currentStepIndex: stepIdx,
|
|
currentInstruction: text,
|
|
nextInstruction: upcomingText,
|
|
currentManeuverModifier: modifier,
|
|
));
|
|
|
|
ttsService.speak(text);
|
|
|
|
CarPlatformBridge.updateNavState(
|
|
lat: pos.latitude,
|
|
lng: pos.longitude,
|
|
bearing: heading,
|
|
speed: speedKmH,
|
|
instruction: text,
|
|
distanceToStep: distToStep,
|
|
totalDistance: state.remainingDistance,
|
|
eta: state.remainingDuration,
|
|
maneuver: modifier,
|
|
isNavigating: true,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Mathematical map matching & deviation check with opposite lane rejection
|
|
_checkOffRoute(pos, heading, speedKmH);
|
|
}
|
|
|
|
void _checkOffRoute(LatLng pos, double heading, double speedKmH) {
|
|
if (state.currentRoute == null || _isRerouting) return;
|
|
|
|
final coords = state.currentRoute!.coordinates;
|
|
if (coords.length < 2) return;
|
|
|
|
// Search window constrained around vehicle's last known route progress
|
|
final int startWindow = (_lastTraveledIndexInFullRoute - 3).clamp(0, coords.length - 2);
|
|
final int endWindow = (_lastTraveledIndexInFullRoute + 45).clamp(0, coords.length - 1);
|
|
|
|
double minDistance = double.infinity;
|
|
int closestSegmentIndex = _lastTraveledIndexInFullRoute;
|
|
|
|
for (int i = startWindow; i < endWindow; i++) {
|
|
final p1 = coords[i];
|
|
final p2 = coords[i + 1];
|
|
|
|
final distToSeg = _distanceToSegment(pos, p1, p2);
|
|
final segBearing = _calculateBearing(p1, p2);
|
|
|
|
// OSM 4-6m Opposite Lane Filter:
|
|
// Dual carriageways in Jordan/MENA are separated by 4-8m.
|
|
// If the driver is moving (> 8 km/h), compute angle difference with the segment.
|
|
// If angle delta > 85° (driving opposite to the segment), add heavy penalty (120m)
|
|
// to guarantee we NEVER snap onto the oncoming opposite lane!
|
|
double effectiveDist = distToSeg;
|
|
if (speedKmH > 8.0) {
|
|
final angleDiff = ((heading - segBearing + 540) % 360) - 180;
|
|
if (angleDiff.abs() > 85.0) {
|
|
effectiveDist += 120.0; // Penalty: opposite direction carriageway
|
|
}
|
|
}
|
|
|
|
if (effectiveDist < minDistance) {
|
|
minDistance = effectiveDist;
|
|
closestSegmentIndex = i;
|
|
}
|
|
}
|
|
|
|
// Check against 50-meter threshold as required by user
|
|
if (minDistance > _offRouteThresholdM) {
|
|
_offRouteStartTime ??= DateTime.now();
|
|
// Sustain deviation for 4 seconds before rerouting to prevent GPS jitter loops
|
|
if (DateTime.now().difference(_offRouteStartTime!).inSeconds >= 4) {
|
|
_recalculateRouteDueToDeviation(pos);
|
|
}
|
|
} else {
|
|
_offRouteStartTime = null;
|
|
// Vehicle is progressing on the route!
|
|
if (closestSegmentIndex > _lastTraveledIndexInFullRoute) {
|
|
_lastTraveledIndexInFullRoute = closestSegmentIndex;
|
|
|
|
// Progressively recalculate remaining distance & duration and update traveled line
|
|
_updateRemainingRouteMetrics(coords, closestSegmentIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _updateRemainingRouteMetrics(List<LatLng> coords, int fromIndex) {
|
|
double remainingM = 0;
|
|
for (int i = fromIndex; i < coords.length - 1; i++) {
|
|
remainingM += locationService.calculateDistance(coords[i], coords[i + 1]);
|
|
}
|
|
final speedMps = (state.speed > 10 ? state.speed : 40.0) / 3.6;
|
|
final remainingSec = remainingM / speedMps;
|
|
|
|
final updatedPolylines = _createPolylines(
|
|
state.routes,
|
|
state.selectedRouteIndex,
|
|
traveledIndex: fromIndex,
|
|
);
|
|
|
|
emit(state.copyWith(
|
|
remainingDistance: remainingM,
|
|
remainingDuration: remainingSec,
|
|
arrivalTime: _calculateArrivalTime(remainingSec),
|
|
polylines: updatedPolylines,
|
|
));
|
|
}
|
|
|
|
Future<void> _recalculateRouteDueToDeviation(LatLng pos) async {
|
|
if (_isRerouting || state.destination == null) return;
|
|
_isRerouting = true;
|
|
_offRouteStartTime = null;
|
|
|
|
ttsService.speak('إعادة حساب المسار...');
|
|
|
|
try {
|
|
final routes = await repository.getRoute(
|
|
origin: pos,
|
|
destination: state.destination!,
|
|
);
|
|
|
|
if (routes.isNotEmpty) {
|
|
final newRoute = routes.first;
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
final polylines = _createPolylines(routes, 0);
|
|
|
|
emit(state.copyWith(
|
|
routes: routes,
|
|
selectedRouteIndex: 0,
|
|
routeSteps: newRoute.steps,
|
|
currentStepIndex: 0,
|
|
remainingDistance: newRoute.distanceM,
|
|
remainingDuration: newRoute.durationS,
|
|
polylines: polylines,
|
|
arrivalTime: _calculateArrivalTime(newRoute.durationS),
|
|
currentInstruction: newRoute.steps.isNotEmpty
|
|
? (newRoute.steps[0]['text']?.toString() ?? 'تابع السير')
|
|
: 'تابع السير',
|
|
));
|
|
}
|
|
} catch (_) {}
|
|
|
|
_isRerouting = false;
|
|
}
|
|
|
|
// ── USER SUBMISSIONS: PLACES & HAZARDS (WITH PIN PICKER) ─────
|
|
|
|
void startLocationPicking(String mode) {
|
|
final targetLoc = mapController?.cameraPosition?.target ?? state.myLocation ?? const LatLng(31.9539, 35.9106);
|
|
final pickMarker = Marker(
|
|
markerId: const MarkerId('picker_pin'),
|
|
position: targetLoc,
|
|
icon: InlqBitmap.fromStyleImage('dest_icon'),
|
|
anchor: const Offset(0.5, 1.0),
|
|
infoWindow: InfoWindow(
|
|
title: mode == 'place' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد',
|
|
),
|
|
zIndex: 95,
|
|
);
|
|
|
|
final updatedMarkers = Set<Marker>.from(state.markers)
|
|
..removeWhere((m) => m.markerId.value == 'picker_pin')
|
|
..add(pickMarker);
|
|
|
|
emit(state.copyWith(
|
|
isSelectingLocationOnMap: true,
|
|
activePickerMode: mode,
|
|
pickedLocation: targetLoc,
|
|
markers: updatedMarkers,
|
|
));
|
|
|
|
if (mapController != null && _isMapStyleLoaded) {
|
|
mapController!.addMarker(pickMarker);
|
|
}
|
|
}
|
|
|
|
void updatePickedLocation(LatLng newLoc) {
|
|
if (!state.isSelectingLocationOnMap) return;
|
|
|
|
final pickMarker = Marker(
|
|
markerId: const MarkerId('picker_pin'),
|
|
position: newLoc,
|
|
icon: InlqBitmap.fromStyleImage('dest_icon'),
|
|
anchor: const Offset(0.5, 1.0),
|
|
infoWindow: InfoWindow(
|
|
title: state.activePickerMode == 'place' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد',
|
|
),
|
|
zIndex: 95,
|
|
);
|
|
|
|
final updatedMarkers = Set<Marker>.from(state.markers)
|
|
..removeWhere((m) => m.markerId.value == 'picker_pin')
|
|
..add(pickMarker);
|
|
|
|
emit(state.copyWith(
|
|
pickedLocation: newLoc,
|
|
markers: updatedMarkers,
|
|
));
|
|
|
|
if (mapController != null && _isMapStyleLoaded) {
|
|
mapController!.addMarker(pickMarker);
|
|
}
|
|
}
|
|
|
|
void cancelLocationPicking() {
|
|
final remainingMarkers = Set<Marker>.from(state.markers)
|
|
..removeWhere((m) => m.markerId.value == 'picker_pin');
|
|
|
|
emit(state.copyWith(
|
|
isSelectingLocationOnMap: false,
|
|
clearActivePickerMode: true,
|
|
clearPickedLocation: true,
|
|
markers: remainingMarkers,
|
|
));
|
|
|
|
if (mapController != null && _isMapStyleLoaded) {
|
|
mapController!.removeMarker(const MarkerId('picker_pin'));
|
|
}
|
|
}
|
|
|
|
Future<bool> submitPlace(String name, String category, {LatLng? position}) async {
|
|
final targetPos = position ?? state.pickedLocation ?? mapController?.cameraPosition?.target ?? state.myLocation;
|
|
if (targetPos == null) return false;
|
|
|
|
final success = await repository.submitNewPlace(
|
|
name: name,
|
|
category: category,
|
|
position: targetPos,
|
|
altitude: state.altitude,
|
|
);
|
|
|
|
if (success) {
|
|
cancelLocationPicking();
|
|
}
|
|
return success;
|
|
}
|
|
|
|
Future<bool> reportHazard({
|
|
required String type,
|
|
required String title,
|
|
required String description,
|
|
LatLng? position,
|
|
}) async {
|
|
final targetPos = position ?? state.pickedLocation ?? state.myLocation;
|
|
if (targetPos == null) return false;
|
|
|
|
final hazard = HazardModel(
|
|
type: type,
|
|
title: title,
|
|
description: description,
|
|
latitude: targetPos.latitude,
|
|
longitude: targetPos.longitude,
|
|
altitude: state.altitude,
|
|
createdAt: DateTime.now(),
|
|
);
|
|
|
|
final success = await repository.reportHazard(hazard);
|
|
if (success) {
|
|
cancelLocationPicking();
|
|
}
|
|
return success;
|
|
}
|
|
|
|
// ── HELPERS ──────────────────────────────────────────────────
|
|
|
|
double _calculateBearing(LatLng from, LatLng to) {
|
|
final lat1 = from.latitude * (pi / 180.0);
|
|
final lon1 = from.longitude * (pi / 180.0);
|
|
final lat2 = to.latitude * (pi / 180.0);
|
|
final lon2 = to.longitude * (pi / 180.0);
|
|
final dLon = lon2 - lon1;
|
|
|
|
final y = sin(dLon) * cos(lat2);
|
|
final x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon);
|
|
final radians = atan2(y, x);
|
|
return (radians * (180.0 / pi) + 360.0) % 360.0;
|
|
}
|
|
|
|
double _distanceToSegment(LatLng p, LatLng a, LatLng b) {
|
|
final double midLatRad = (a.latitude + b.latitude) * 0.5 * (pi / 180.0);
|
|
final double cosMid = cos(midLatRad);
|
|
|
|
final double dx = (b.longitude - a.longitude) * cosMid;
|
|
final double dy = b.latitude - a.latitude;
|
|
final double segLenSquared = dx * dx + dy * dy;
|
|
|
|
if (segLenSquared <= 1e-12) {
|
|
return locationService.calculateDistance(p, a);
|
|
}
|
|
|
|
final double px = (p.longitude - a.longitude) * cosMid;
|
|
final double py = p.latitude - a.latitude;
|
|
|
|
final double t = ((px * dx + py * dy) / segLenSquared).clamp(0.0, 1.0);
|
|
final double projLat = a.latitude + t * (b.latitude - a.latitude);
|
|
final double projLng = a.longitude + t * (b.longitude - a.longitude);
|
|
|
|
return locationService.calculateDistance(p, LatLng(projLat, projLng));
|
|
}
|
|
|
|
Set<Polyline> _createPolylines(List<RouteData> routes, int activeIndex, {int traveledIndex = 0}) {
|
|
final Set<Polyline> set = {};
|
|
|
|
// 1. Render alternative routes first
|
|
for (int i = 0; i < routes.length; i++) {
|
|
if (i == activeIndex) continue;
|
|
set.add(Polyline(
|
|
polylineId: PolylineId('route_$i'),
|
|
points: routes[i].coordinates,
|
|
color: const Color(0xFF90A4AE).withValues(alpha: 0.6),
|
|
width: 5,
|
|
));
|
|
}
|
|
|
|
// 2. Render selected active route
|
|
if (activeIndex >= 0 && activeIndex < routes.length) {
|
|
final activeCoords = routes[activeIndex].coordinates;
|
|
if (traveledIndex > 0 && traveledIndex < activeCoords.length) {
|
|
// Traveled portion (muted gray)
|
|
set.add(Polyline(
|
|
polylineId: const PolylineId('route_traveled'),
|
|
points: activeCoords.sublist(0, traveledIndex + 1),
|
|
color: const Color(0xFF90A4AE).withValues(alpha: 0.5),
|
|
width: 6,
|
|
));
|
|
// Remaining portion (active Apple blue)
|
|
set.add(Polyline(
|
|
polylineId: const PolylineId('route_remaining'),
|
|
points: activeCoords.sublist(traveledIndex),
|
|
color: AppColors.appleBlue,
|
|
width: 7,
|
|
));
|
|
} else {
|
|
set.add(Polyline(
|
|
polylineId: PolylineId('route_$activeIndex'),
|
|
points: activeCoords,
|
|
color: AppColors.appleBlue,
|
|
width: 7,
|
|
));
|
|
}
|
|
}
|
|
|
|
return set;
|
|
}
|
|
|
|
void _fitRouteInView(List<LatLng> coords) {
|
|
if (coords.isEmpty || mapController == null || !_isMapStyleLoaded) {
|
|
print("⚠️ [NavigationCubit] _fitRouteInView deferred/skipped (coordsCount=${coords.length}, mapController=${mapController != null}, styleLoaded=$_isMapStyleLoaded)");
|
|
return;
|
|
}
|
|
double minLat = coords.first.latitude;
|
|
double maxLat = coords.first.latitude;
|
|
double minLng = coords.first.longitude;
|
|
double maxLng = coords.first.longitude;
|
|
|
|
for (var c in coords) {
|
|
if (c.latitude < minLat) minLat = c.latitude;
|
|
if (c.latitude > maxLat) maxLat = c.latitude;
|
|
if (c.longitude < minLng) minLng = c.longitude;
|
|
if (c.longitude > maxLng) maxLng = c.longitude;
|
|
}
|
|
|
|
print("🎬 [NavigationCubit] _fitRouteInView: bounds SW=($minLat, $minLng), NE=($maxLat, $maxLng)");
|
|
mapController!.animateCamera(
|
|
CameraUpdate.newLatLngBounds(
|
|
LatLngBounds(
|
|
southwest: LatLng(minLat, minLng),
|
|
northeast: LatLng(maxLat, maxLng),
|
|
),
|
|
left: 50,
|
|
right: 50,
|
|
top: 100,
|
|
bottom: 220,
|
|
),
|
|
);
|
|
}
|
|
|
|
String _calculateArrivalTime(double durationSeconds) {
|
|
final arrival = DateTime.now().add(Duration(seconds: durationSeconds.round()));
|
|
return DateFormat('hh:mm a').format(arrival);
|
|
}
|
|
|
|
@override
|
|
Future<void> close() {
|
|
_movementInterpolationTimer?.cancel();
|
|
_positionStreamSub?.cancel();
|
|
_connectivitySub?.cancel();
|
|
_searchDebounce?.cancel();
|
|
ttsService.stop();
|
|
TelemetryTrackingService.instance.dispose(repository: repository);
|
|
return super.close();
|
|
}
|
|
}
|