feat: add vehicle customization, Arabic search normalization, and smooth movement interpolation

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:25 +03:00
parent 43ad2e0ad4
commit 55830beee8
16 changed files with 2295 additions and 393 deletions
@@ -13,6 +13,8 @@ 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 '../../../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';
@@ -48,11 +50,40 @@ class NavigationCubit extends Cubit<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();
// 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();
@@ -78,6 +109,8 @@ class NavigationCubit extends Cubit<NavigationState> {
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");
@@ -89,12 +122,12 @@ class NavigationCubit extends Cubit<NavigationState> {
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));
// Defer camera animation to onStyleLoaded to prevent iOS native crashes
}
Future<void> _animateCameraToCurrentPosition() async {
@@ -109,19 +142,19 @@ class NavigationCubit extends Cubit<NavigationState> {
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,
));
_currentDisplayPosition = loc;
_currentDisplayHeading = pos.heading;
_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(
@@ -131,36 +164,47 @@ class NavigationCubit extends Cubit<NavigationState> {
),
),
);
} else {
print("⏳ [NavigationCubit] Camera animation queued/deferred (styleLoaded=$_isMapStyleLoaded, controller=${mapController != null})");
}
}
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());
print("🚗 [NavigationCubit] car_icon registered into map style successfully.");
} catch (e) {
print("⚠️ [NavigationCubit] Could not load car_icon asset: $e");
}
} catch (_) {}
// 3. Start & Destination pins
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");
}
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());
print("📍 [NavigationCubit] dest_icon (Pin B) registered successfully.");
} catch (e) {
print("⚠️ [NavigationCubit] Could not load dest_icon asset: $e");
}
await mapController!.addImage('asset_assets_images_b_png', destBytes.buffer.asUint8List());
} catch (_) {}
}
Future<void> onStyleLoaded() async {
@@ -168,11 +212,50 @@ class NavigationCubit extends Cubit<NavigationState> {
_isMapStyleLoaded = true;
await _loadCustomIcons();
if (state.myLocation != null) {
_updateCarMarker(state.myLocation!, state.heading);
_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) {
@@ -181,69 +264,183 @@ class NavigationCubit extends Cubit<NavigationState> {
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,
));
_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);
}
// 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,
);
}
// 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),
),
);
}
// 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) {
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],
);
}
if (_animCurrentStep % 5 == 0 || _animCurrentStep == _animTotalSteps) {
_updateNavigationCamera(interpPos, interpHeading, state.speed, state.distanceToNextStep);
}
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: newLoc,
zoom: 17.5,
tilt: 55.0,
bearing: effectiveBearing,
),
),
);
}
if (state.isNavigating) {
_processActiveNavigationTick(newLoc, speedKmH, heading);
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 {
@@ -253,7 +450,7 @@ class NavigationCubit extends Cubit<NavigationState> {
rotation: bearing,
anchor: const Offset(0.5, 0.5),
flat: true,
icon: InlqBitmap.fromStyleImage('car_icon'),
icon: InlqBitmap.fromStyleImage('current_vehicle_icon', size: state.vehicleScale),
zIndex: 100,
));
} catch (e) {
@@ -269,29 +466,25 @@ class NavigationCubit extends Cubit<NavigationState> {
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,
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,
),
),
),
);
} else {
print("⏳ [NavigationCubit] relockCameraToUser deferred: styleLoaded=$_isMapStyleLoaded, controller=${mapController != null}");
);
}
}
}
@@ -316,25 +509,102 @@ class NavigationCubit extends Cubit<NavigationState> {
void onSearchChanged(String query) {
_searchDebounce?.cancel();
if (query.trim().length < 2) {
emit(state.copyWith(searchResults: []));
final trimmed = query.trim();
if (trimmed.length < 2) {
emit(state.copyWith(searchResults: [], isSearching: false));
return;
}
_searchDebounce = Timer(const Duration(milliseconds: 400), () async {
emit(state.copyWith(isSearching: true));
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
final results = await repository.searchPlaces(
query: query,
query: trimmed,
userLocation: state.myLocation,
);
emit(state.copyWith(searchResults: results));
emit(state.copyWith(searchResults: results, isSearching: false));
});
}
void clearSearch() {
emit(state.copyWith(searchResults: []));
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> calculateRouteTo(LatLng destination, {String title = 'وجهة مختارة'}) async {
print("🛣️ [NavigationCubit] calculateRouteTo: target=$destination, title=$title, myLocation=${state.myLocation}");
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;
@@ -343,6 +613,7 @@ class NavigationCubit extends Cubit<NavigationState> {
status: NavigationStatus.loading,
destination: destination,
destinationTitle: title,
originTitle: originTitle,
searchResults: [],
));
@@ -370,9 +641,9 @@ class NavigationCubit extends Cubit<NavigationState> {
final startMarker = Marker(
markerId: const MarkerId('origin_pin'),
position: state.myLocation!,
icon: InlqBitmap.fromAsset('assets/images/A.png'),
icon: InlqBitmap.fromStyleImage('start_icon'),
anchor: const Offset(0.5, 1.0),
infoWindow: const InfoWindow(title: 'نقطة الانطلاق (أ)'),
infoWindow: InfoWindow(title: originTitle, snippet: 'start'),
zIndex: 90,
);
@@ -380,9 +651,9 @@ class NavigationCubit extends Cubit<NavigationState> {
final destMarker = Marker(
markerId: const MarkerId('dest_pin'),
position: destination,
icon: InlqBitmap.fromAsset('assets/images/b.png'),
icon: InlqBitmap.fromStyleImage('dest_icon'),
anchor: const Offset(0.5, 1.0),
infoWindow: InfoWindow(title: title),
infoWindow: InfoWindow(title: title, snippet: 'end'),
zIndex: 90,
);
@@ -395,6 +666,9 @@ class NavigationCubit extends Cubit<NavigationState> {
status: NavigationStatus.routePreview,
routes: routes,
selectedRouteIndex: 0,
destination: destination,
destinationTitle: title,
originTitle: originTitle,
routeSteps: primaryRoute.steps,
remainingDistance: primaryRoute.distanceM,
remainingDuration: primaryRoute.durationS,
@@ -403,6 +677,12 @@ class NavigationCubit extends Cubit<NavigationState> {
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");
@@ -467,22 +747,15 @@ class NavigationCubit extends Cubit<NavigationState> {
ttsService.speak(firstInstruction);
// Immediate 3D Heading-Up camera orientation
// 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]);
}
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: state.myLocation!,
zoom: 17.5,
tilt: 55.0,
bearing: bearing,
),
),
);
_currentDisplayPosition = state.myLocation;
_currentDisplayHeading = bearing;
_updateNavigationCamera(state.myLocation!, bearing, state.speed, 100.0);
}
CarPlatformBridge.updateNavState(
@@ -501,14 +774,22 @@ class NavigationCubit extends Cubit<NavigationState> {
void stopNavigation() {
print("🛑 [NavigationCubit] stopNavigation triggered.");
_movementInterpolationTimer?.cancel();
_movementInterpolationTimer = null;
ttsService.stop();
CarPlatformBridge.stopNavigation();
_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: [],
@@ -517,6 +798,7 @@ class NavigationCubit extends Cubit<NavigationState> {
markers: remainingMarkers,
destination: null,
destinationTitle: '',
originTitle: 'موقعي الحالي',
currentInstruction: '',
nextInstruction: '',
isCameraLocked: true,
@@ -531,16 +813,7 @@ class NavigationCubit extends Cubit<NavigationState> {
}
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);
}
_onNewLocationFix(pos, heading, speed, altitude);
}
void _processActiveNavigationTick(LatLng pos, double speedKmH, double heading) {
@@ -588,6 +861,16 @@ class NavigationCubit extends Cubit<NavigationState> {
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;
@@ -595,6 +878,7 @@ class NavigationCubit extends Cubit<NavigationState> {
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() ?? '') : '';
@@ -746,22 +1030,94 @@ class NavigationCubit extends Cubit<NavigationState> {
_isRerouting = false;
}
// ── USER SUBMISSIONS: PLACES & HAZARDS ──────────────────────
// ── USER SUBMISSIONS: PLACES & HAZARDS (WITH PIN PICKER) ─────
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;
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: center,
position: targetPos,
altitude: state.altitude,
);
if (success) {
emit(state.copyWith(isSelectingLocationOnMap: false));
cancelLocationPicking();
}
return success;
}
@@ -770,20 +1126,26 @@ class NavigationCubit extends Cubit<NavigationState> {
required String type,
required String title,
required String description,
LatLng? position,
}) async {
if (state.myLocation == null) return false;
final targetPos = position ?? state.pickedLocation ?? state.myLocation;
if (targetPos == null) return false;
final hazard = HazardModel(
type: type,
title: title,
description: description,
latitude: state.myLocation!.latitude,
longitude: state.myLocation!.longitude,
latitude: targetPos.latitude,
longitude: targetPos.longitude,
altitude: state.altitude,
createdAt: DateTime.now(),
);
return await repository.reportHazard(hazard);
final success = await repository.reportHazard(hazard);
if (success) {
cancelLocationPicking();
}
return success;
}
// ── HELPERS ──────────────────────────────────────────────────
@@ -907,6 +1269,7 @@ class NavigationCubit extends Cubit<NavigationState> {
@override
Future<void> close() {
_movementInterpolationTimer?.cancel();
_positionStreamSub?.cancel();
_connectivitySub?.cancel();
_searchDebounce?.cancel();
@@ -29,6 +29,7 @@ class NavigationState extends Equatable {
final int selectedRouteIndex;
final LatLng? destination;
final String destinationTitle;
final String originTitle;
final List<Map<String, dynamic>> routeSteps;
final int currentStepIndex;
final String currentInstruction;
@@ -45,8 +46,15 @@ class NavigationState extends Equatable {
final Set<Polyline> polylines;
final List<PlaceModel> searchResults;
final bool isSelectingLocationOnMap;
final String? activePickerMode;
final LatLng? pickedLocation;
final bool isOnline;
final String? errorMessage;
final int selectedVehicleColor;
final String selectedVehicleStyle;
final double vehicleScale;
final List<String> recentSearches;
final bool isSearching;
const NavigationState({
this.status = NavigationStatus.initial,
@@ -58,6 +66,7 @@ class NavigationState extends Equatable {
this.selectedRouteIndex = 0,
this.destination,
this.destinationTitle = '',
this.originTitle = 'موقعي الحالي',
this.routeSteps = const [],
this.currentStepIndex = 0,
this.currentInstruction = '',
@@ -74,8 +83,15 @@ class NavigationState extends Equatable {
this.polylines = const {},
this.searchResults = const [],
this.isSelectingLocationOnMap = false,
this.activePickerMode,
this.pickedLocation,
this.isOnline = true,
this.errorMessage,
this.selectedVehicleColor = 0xFF007AFF,
this.selectedVehicleStyle = 'car',
this.vehicleScale = 1.8,
this.recentSearches = const [],
this.isSearching = false,
});
RouteData? get currentRoute =>
@@ -112,6 +128,7 @@ class NavigationState extends Equatable {
int? selectedRouteIndex,
LatLng? destination,
String? destinationTitle,
String? originTitle,
List<Map<String, dynamic>>? routeSteps,
int? currentStepIndex,
String? currentInstruction,
@@ -128,8 +145,17 @@ class NavigationState extends Equatable {
Set<Polyline>? polylines,
List<PlaceModel>? searchResults,
bool? isSelectingLocationOnMap,
String? activePickerMode,
bool clearActivePickerMode = false,
LatLng? pickedLocation,
bool clearPickedLocation = false,
bool? isOnline,
String? errorMessage,
int? selectedVehicleColor,
String? selectedVehicleStyle,
double? vehicleScale,
List<String>? recentSearches,
bool? isSearching,
}) {
return NavigationState(
status: status ?? this.status,
@@ -141,6 +167,7 @@ class NavigationState extends Equatable {
selectedRouteIndex: selectedRouteIndex ?? this.selectedRouteIndex,
destination: destination ?? this.destination,
destinationTitle: destinationTitle ?? this.destinationTitle,
originTitle: originTitle ?? this.originTitle,
routeSteps: routeSteps ?? this.routeSteps,
currentStepIndex: currentStepIndex ?? this.currentStepIndex,
currentInstruction: currentInstruction ?? this.currentInstruction,
@@ -159,8 +186,15 @@ class NavigationState extends Equatable {
searchResults: searchResults ?? this.searchResults,
isSelectingLocationOnMap:
isSelectingLocationOnMap ?? this.isSelectingLocationOnMap,
activePickerMode: clearActivePickerMode ? null : (activePickerMode ?? this.activePickerMode),
pickedLocation: clearPickedLocation ? null : (pickedLocation ?? this.pickedLocation),
isOnline: isOnline ?? this.isOnline,
errorMessage: errorMessage ?? this.errorMessage,
selectedVehicleColor: selectedVehicleColor ?? this.selectedVehicleColor,
selectedVehicleStyle: selectedVehicleStyle ?? this.selectedVehicleStyle,
vehicleScale: vehicleScale ?? this.vehicleScale,
recentSearches: recentSearches ?? this.recentSearches,
isSearching: isSearching ?? this.isSearching,
);
}
@@ -175,6 +209,7 @@ class NavigationState extends Equatable {
selectedRouteIndex,
destination,
destinationTitle,
originTitle,
routeSteps,
currentStepIndex,
currentInstruction,
@@ -191,7 +226,14 @@ class NavigationState extends Equatable {
polylines,
searchResults,
isSelectingLocationOnMap,
activePickerMode,
pickedLocation,
isOnline,
errorMessage,
selectedVehicleColor,
selectedVehicleStyle,
vehicleScale,
recentSearches,
isSearching,
];
}