chore: add build artifacts, map assets, and routing data

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:24 +03:00
parent aa2b9f131f
commit 43ad2e0ad4
327 changed files with 52421 additions and 1810 deletions
@@ -0,0 +1,916 @@
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/tts_service.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;
DateTime? _lastTelemetrySent;
final String _driverId = 'driver_${DateTime.now().millisecondsSinceEpoch % 100000}';
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();
}
Future<void> _init() async {
print("🚀 [NavigationCubit] Initializing NavigationCubit...");
CarPlatformBridge.ensureInitialized();
await ttsService.init();
// 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,
));
_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;
void onMapCreated(IntaleqMapController controller) {
print("🗺️ [NavigationCubit] onMapCreated: Native map view created, controller attached.");
mapController = controller;
emit(state.copyWith(status: NavigationStatus.mapReady));
// Defer camera animation to onStyleLoaded to prevent iOS native crashes
}
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;
print("📍 [NavigationCubit] Updated GPS target from fresh fix: lat=${loc.latitude.toStringAsFixed(6)}, lng=${loc.longitude.toStringAsFixed(6)}");
emit(state.copyWith(
myLocation: loc,
altitude: alt,
heading: pos.heading,
speed: pos.speed * 3.6,
));
_updateCarMarker(loc, pos.heading);
}
}
if (target != null && mapController != null && _isMapStyleLoaded) {
print("🎬 [NavigationCubit] Animating camera to target: lat=${target.latitude.toStringAsFixed(6)}, lng=${target.longitude.toStringAsFixed(6)}, zoom=16.5, bearing=$heading");
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: target,
zoom: 16.5,
bearing: heading,
),
),
);
} else {
print("⏳ [NavigationCubit] Camera animation queued/deferred (styleLoaded=$_isMapStyleLoaded, controller=${mapController != null})");
}
}
Future<void> _loadCustomIcons() async {
if (mapController == null) return;
try {
final carBytes = await rootBundle.load('assets/images/car.png');
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
print("🚗 [NavigationCubit] car_icon registered into map style successfully.");
} catch (e) {
print("⚠️ [NavigationCubit] Could not load car_icon asset: $e");
}
try {
final startBytes = await rootBundle.load('assets/images/A.png');
await mapController!.addImage('start_icon', startBytes.buffer.asUint8List());
print("📍 [NavigationCubit] start_icon (Pin A) registered successfully.");
} catch (e) {
print("⚠️ [NavigationCubit] Could not load start_icon asset: $e");
}
try {
final destBytes = await rootBundle.load('assets/images/b.png');
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
print("📍 [NavigationCubit] dest_icon (Pin B) registered successfully.");
} catch (e) {
print("⚠️ [NavigationCubit] Could not load dest_icon asset: $e");
}
}
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(state.myLocation!, state.heading);
}
_animateCameraToCurrentPosition();
}
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;
emit(state.copyWith(
myLocation: newLoc,
altitude: alt,
heading: heading,
speed: speedKmH,
));
_updateCarMarker(newLoc, heading);
// Periodic driver telemetry stream (every 5 seconds)
final now = DateTime.now();
if (_lastTelemetrySent == null || now.difference(_lastTelemetrySent!).inSeconds >= 5) {
_lastTelemetrySent = now;
repository.sendDriverTelemetry(
driverId: _driverId,
latitude: newLoc.latitude,
longitude: newLoc.longitude,
speed: speedKmH,
heading: heading,
elevation: alt,
distance: 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.isCameraLocked && mapController != null && _isMapStyleLoaded && state.isNavigating) {
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],
);
}
}
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: newLoc,
zoom: 17.5,
tilt: 55.0,
bearing: effectiveBearing,
),
),
);
}
if (state.isNavigating) {
_processActiveNavigationTick(newLoc, speedKmH, heading);
}
});
}
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('car_icon'),
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) {
double effectiveBearing = state.heading;
if (state.isNavigating && state.speed < 4.0 && state.currentRoute != null) {
final coords = state.currentRoute!.coordinates;
if (_lastTraveledIndexInFullRoute + 1 < coords.length) {
effectiveBearing = _calculateBearing(
coords[_lastTraveledIndexInFullRoute],
coords[_lastTraveledIndexInFullRoute + 1],
);
}
}
print("🎬 [NavigationCubit] Centering camera on user: target=${state.myLocation}, zoom=${state.isNavigating ? 17.5 : 16.5}, tilt=${state.isNavigating ? 55.0 : 0.0}, bearing=$effectiveBearing");
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: state.myLocation!,
zoom: state.isNavigating ? 17.5 : 16.5,
tilt: state.isNavigating ? 55.0 : 0.0,
bearing: effectiveBearing,
),
),
);
} else {
print("⏳ [NavigationCubit] relockCameraToUser deferred: styleLoaded=$_isMapStyleLoaded, controller=${mapController != null}");
}
}
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();
if (query.trim().length < 2) {
emit(state.copyWith(searchResults: []));
return;
}
_searchDebounce = Timer(const Duration(milliseconds: 400), () async {
final results = await repository.searchPlaces(
query: query,
userLocation: state.myLocation,
);
emit(state.copyWith(searchResults: results));
});
}
void clearSearch() {
emit(state.copyWith(searchResults: []));
}
Future<void> calculateRouteTo(LatLng destination, {String title = 'وجهة مختارة'}) async {
print("🛣️ [NavigationCubit] calculateRouteTo: 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,
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.fromAsset('assets/images/A.png'),
anchor: const Offset(0.5, 1.0),
infoWindow: const InfoWindow(title: 'نقطة الانطلاق (أ)'),
zIndex: 90,
);
// Destination Pin B
final destMarker = Marker(
markerId: const MarkerId('dest_pin'),
position: destination,
icon: InlqBitmap.fromAsset('assets/images/b.png'),
anchor: const Offset(0.5, 1.0),
infoWindow: InfoWindow(title: title),
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,
routeSteps: primaryRoute.steps,
remainingDistance: primaryRoute.distanceM,
remainingDuration: primaryRoute.durationS,
polylines: polylines,
markers: updatedMarkers,
arrivalTime: _calculateArrivalTime(primaryRoute.durationS),
));
_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
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]);
}
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: state.myLocation!,
zoom: 17.5,
tilt: 55.0,
bearing: bearing,
),
),
);
}
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,
);
}
void stopNavigation() {
print("🛑 [NavigationCubit] stopNavigation triggered.");
ttsService.stop();
CarPlatformBridge.stopNavigation();
_lastTraveledIndexInFullRoute = 0;
_offRouteStartTime = null;
final remainingMarkers = Set<Marker>.from(state.markers)
..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin');
emit(state.copyWith(
status: NavigationStatus.mapReady,
routes: [],
routeSteps: [],
polylines: {},
markers: remainingMarkers,
destination: null,
destinationTitle: '',
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}) {
emit(state.copyWith(
myLocation: pos,
altitude: altitude,
speed: speed,
heading: heading,
));
_updateCarMarker(pos, heading);
if (state.isNavigating) {
_processActiveNavigationTick(pos, speed, heading);
}
}
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));
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++;
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 ──────────────────────
Future<bool> submitPlace(String name, String category) async {
if (mapController == null) return false;
final center = mapController!.cameraPosition?.target ?? state.myLocation;
if (center == null) return false;
final success = await repository.submitNewPlace(
name: name,
category: category,
position: center,
altitude: state.altitude,
);
if (success) {
emit(state.copyWith(isSelectingLocationOnMap: false));
}
return success;
}
Future<bool> reportHazard({
required String type,
required String title,
required String description,
}) async {
if (state.myLocation == null) return false;
final hazard = HazardModel(
type: type,
title: title,
description: description,
latitude: state.myLocation!.latitude,
longitude: state.myLocation!.longitude,
altitude: state.altitude,
createdAt: DateTime.now(),
);
return await repository.reportHazard(hazard);
}
// ── 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() {
_positionStreamSub?.cancel();
_connectivitySub?.cancel();
_searchDebounce?.cancel();
ttsService.stop();
return super.close();
}
}
@@ -0,0 +1,197 @@
import 'package:equatable/equatable.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import '../../../data/models/route_model.dart';
import '../../../data/models/place_model.dart';
enum NavigationStatus {
initial,
loading,
mapReady,
routePreview,
navigating,
arrived,
error,
}
enum MapThemeType {
vectorLight,
vectorDark,
satellite,
}
class NavigationState extends Equatable {
final NavigationStatus status;
final LatLng? myLocation;
final double altitude; // Altitude AMSL in meters (defaults to 0.0)
final double heading;
final double speed;
final List<RouteData> routes;
final int selectedRouteIndex;
final LatLng? destination;
final String destinationTitle;
final List<Map<String, dynamic>> routeSteps;
final int currentStepIndex;
final String currentInstruction;
final String nextInstruction;
final double distanceToNextStep;
final double remainingDistance;
final double remainingDuration;
final int currentManeuverModifier;
final String arrivalTime;
final bool isMuted;
final bool isCameraLocked;
final MapThemeType mapTheme;
final Set<Marker> markers;
final Set<Polyline> polylines;
final List<PlaceModel> searchResults;
final bool isSelectingLocationOnMap;
final bool isOnline;
final String? errorMessage;
const NavigationState({
this.status = NavigationStatus.initial,
this.myLocation,
this.altitude = 0.0,
this.heading = 0.0,
this.speed = 0.0,
this.routes = const [],
this.selectedRouteIndex = 0,
this.destination,
this.destinationTitle = '',
this.routeSteps = const [],
this.currentStepIndex = 0,
this.currentInstruction = '',
this.nextInstruction = '',
this.distanceToNextStep = 0.0,
this.remainingDistance = 0.0,
this.remainingDuration = 0.0,
this.currentManeuverModifier = 0,
this.arrivalTime = '--:--',
this.isMuted = false,
this.isCameraLocked = true,
this.mapTheme = MapThemeType.vectorLight,
this.markers = const {},
this.polylines = const {},
this.searchResults = const [],
this.isSelectingLocationOnMap = false,
this.isOnline = true,
this.errorMessage,
});
RouteData? get currentRoute =>
routes.isNotEmpty && selectedRouteIndex < routes.length
? routes[selectedRouteIndex]
: null;
bool get isNavigating => status == NavigationStatus.navigating;
String get formattedRemainingDistance {
if (remainingDistance >= 1000) {
return '${(remainingDistance / 1000).toStringAsFixed(1)} كم';
}
return '${remainingDistance.round()} م';
}
String get formattedRemainingDuration {
final int minutes = (remainingDuration / 60).round();
if (minutes >= 60) {
final int hours = minutes ~/ 60;
final int remMin = minutes % 60;
return '$hours س $remMin د';
}
return '$minutes دقيقة';
}
NavigationState copyWith({
NavigationStatus? status,
LatLng? myLocation,
double? altitude,
double? heading,
double? speed,
List<RouteData>? routes,
int? selectedRouteIndex,
LatLng? destination,
String? destinationTitle,
List<Map<String, dynamic>>? routeSteps,
int? currentStepIndex,
String? currentInstruction,
String? nextInstruction,
double? distanceToNextStep,
double? remainingDistance,
double? remainingDuration,
int? currentManeuverModifier,
String? arrivalTime,
bool? isMuted,
bool? isCameraLocked,
MapThemeType? mapTheme,
Set<Marker>? markers,
Set<Polyline>? polylines,
List<PlaceModel>? searchResults,
bool? isSelectingLocationOnMap,
bool? isOnline,
String? errorMessage,
}) {
return NavigationState(
status: status ?? this.status,
myLocation: myLocation ?? this.myLocation,
altitude: altitude ?? this.altitude,
heading: heading ?? this.heading,
speed: speed ?? this.speed,
routes: routes ?? this.routes,
selectedRouteIndex: selectedRouteIndex ?? this.selectedRouteIndex,
destination: destination ?? this.destination,
destinationTitle: destinationTitle ?? this.destinationTitle,
routeSteps: routeSteps ?? this.routeSteps,
currentStepIndex: currentStepIndex ?? this.currentStepIndex,
currentInstruction: currentInstruction ?? this.currentInstruction,
nextInstruction: nextInstruction ?? this.nextInstruction,
distanceToNextStep: distanceToNextStep ?? this.distanceToNextStep,
remainingDistance: remainingDistance ?? this.remainingDistance,
remainingDuration: remainingDuration ?? this.remainingDuration,
currentManeuverModifier:
currentManeuverModifier ?? this.currentManeuverModifier,
arrivalTime: arrivalTime ?? this.arrivalTime,
isMuted: isMuted ?? this.isMuted,
isCameraLocked: isCameraLocked ?? this.isCameraLocked,
mapTheme: mapTheme ?? this.mapTheme,
markers: markers ?? this.markers,
polylines: polylines ?? this.polylines,
searchResults: searchResults ?? this.searchResults,
isSelectingLocationOnMap:
isSelectingLocationOnMap ?? this.isSelectingLocationOnMap,
isOnline: isOnline ?? this.isOnline,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
status,
myLocation,
altitude,
heading,
speed,
routes,
selectedRouteIndex,
destination,
destinationTitle,
routeSteps,
currentStepIndex,
currentInstruction,
nextInstruction,
distanceToNextStep,
remainingDistance,
remainingDuration,
currentManeuverModifier,
arrivalTime,
isMuted,
isCameraLocked,
mapTheme,
markers,
polylines,
searchResults,
isSelectingLocationOnMap,
isOnline,
errorMessage,
];
}