import { MaterialCommunityIcons } from "@expo/vector-icons"; import { useEffect, useRef, useState } from "react"; import { Platform, StyleSheet, View } from "react-native"; import MapView, { AnimatedRegion, Marker, MarkerAnimated, PROVIDER_DEFAULT, } from "react-native-maps"; import MapViewDirections from "react-native-maps-directions"; import { icons } from "@/constants"; import { SERVICES } from "@/constants/services"; import { tr } from "@/lib/i18n"; import { calculateDriverTimes, calculateRegion, generateMarkersFromData, } from "@/lib/map"; import { useTheme } from "@/lib/theme"; import { useNearbyDrivers } from "@/lib/use-nearby-drivers"; import { useDriverStore, useLocationStore, useServiceStore } from "@/store"; import type { MarkerData } from "@/types/type"; // react-native-maps sizes itself from a real style object, so give it explicit // dimensions rather than relying on percentage classNames resolving to 0. const styles = StyleSheet.create({ map: { ...StyleSheet.absoluteFillObject, borderRadius: 16 }, markerBubble: { width: 34, height: 34, borderRadius: 17, alignItems: "center", justifyContent: "center", backgroundColor: "#111827", borderWidth: 2, borderColor: "#ffffff", // A flat dot on a light map is hard to pick out; a soft shadow lifts it. shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 3, shadowOffset: { width: 0, height: 1 }, elevation: 4, }, markerBubbleSelected: { backgroundColor: "#0286ff", }, // Wraps bubble + arrow so the arrow can orbit the bubble by rotating the // whole frame, while the vehicle glyph inside stays upright and readable. markerFrame: { width: 54, height: 54, alignItems: "center", justifyContent: "center", }, headingArrow: { position: "absolute", top: 0, }, }); // How long a marker takes to slide to its new position. // // Deliberately the poll interval, not less: each update is the car's position // as of that moment, so spreading the movement across the whole gap until the // next one is what makes a series of samples read as continuous travel. A // shorter duration would animate quickly and then sit frozen, which looks // worse than not animating at all. const MARKER_GLIDE_MS = 5000; // Below this the GPS heading is mostly noise — a stationary phone reports // wildly varying directions — so the arrow is hidden and the car is simply // drawn as parked. const MOVING_KPH = 5; // A driver pin drawn as the vehicle they actually drive. // // Every driver used to get the same car marker, so a moto rider watching a // motorbike approach saw a car on their map — and the four services were // indistinguishable at a glance. The glyphs come from the same SERVICES table // the service picker uses, so a pin and its tile always agree. const glyphFor = (service?: string | null) => (SERVICES.find((s) => s.id === service) ?? SERVICES[0]).icon; const ServiceMarker = ({ marker, selected, }: { marker: MarkerData; selected: boolean; }) => { // Android renders a custom marker view by snapshotting it, and a snapshot // taken before layout is blank. Track changes briefly so the first real // frame is captured, then stop — leaving it on re-snapshots every marker on // every frame, which makes a map full of drivers crawl. const [tracksViewChanges, setTracksViewChanges] = useState(true); const heading = marker.heading ?? null; const moving = (marker.speed_kph ?? 0) >= MOVING_KPH; const showArrow = moving && heading !== null; // The marker's own coordinate, animated rather than assigned. // // Positions arrive every few seconds; setting them directly teleports each // car across the gap it covered since the last update. Holding the // coordinate in an AnimatedRegion and easing to each new fix turns the same // samples into visible travel — which is the whole point of showing other // drivers at all. const coordinate = useRef( new AnimatedRegion({ latitude: marker.latitude, longitude: marker.longitude, latitudeDelta: 0, longitudeDelta: 0, }), ).current; useEffect(() => { // `timing` is not on the public typings for AnimatedRegion in this // version, though it exists at runtime; the cast keeps the call honest // without loosening the rest of the component. ( coordinate as unknown as { timing: (config: Record) => { start: () => void; }; } ) .timing({ latitude: marker.latitude, longitude: marker.longitude, latitudeDelta: 0, longitudeDelta: 0, duration: MARKER_GLIDE_MS, // AnimatedRegion drives a native prop that the native driver can't // handle, so this animation runs on the JS thread by necessity. useNativeDriver: false, }) .start(); }, [coordinate, marker.latitude, marker.longitude]); useEffect(() => { setTracksViewChanges(true); const timer = setTimeout(() => setTracksViewChanges(false), 800); return () => clearTimeout(timer); }, [selected, marker.service, showArrow, heading]); // react-native-maps accepts an AnimatedRegion here at runtime — it is what // every animated-marker example passes — but types the prop as an animated // LatLng, so the two don't line up. Cast at the boundary rather than // loosening the component's own types. const animatedCoordinate = coordinate as unknown as React.ComponentProps< typeof MarkerAnimated >["coordinate"]; return ( {/* Rotating the frame swings the arrow around the bubble to point the way the car is travelling, while the bubble itself — and the vehicle glyph in it — stays upright and legible. */} {showArrow ? ( ) : null} ); }; // "mutedStandard" is an Apple Maps type. Android's MapManager looks the value // up in a fixed table and unboxes the result into an int, so an unrecognised // name is a null Integer -> NullPointerException, and the map never draws. const MAP_TYPE = Platform.OS === "ios" ? "mutedStandard" : "standard"; // showsPointsOfInterest is iOS-only; on Android the same muting is done with a // style array, so both platforms get the same clean base map. const MUTED_POI_STYLE = [ { featureType: "poi", elementType: "labels", stylers: [{ visibility: "off" }], }, { featureType: "transit", elementType: "labels.icon", stylers: [{ visibility: "off" }], }, ]; // The single driver assigned to a ride, as returned by GET /ride/:id. Used to // show the rider a live marker for the driver who accepted, instead of the // generic "nearby drivers of this service" search list. type TrackedDriver = { id: number; latitude: number | null; longitude: number | null; first_name?: string | null; last_name?: string | null; profile_image_url?: string | null; car_image_url?: string | null; car_seats?: number | null; rating?: number | null; car_model?: string | null; // Drives the pin glyph, so the rider watching their assigned driver arrive // sees a motorbike when a motorbike is coming. service?: string | null; }; type LatLng = { latitude: number; longitude: number }; export type MapProps = { trackedDriver?: TrackedDriver | null; /** * Show position and nearby drivers only — never a route line, and never zoom * out to fit a destination. * * The home map answers "where am I and what's around me". Drawing the * destination there meant a rider who had merely searched an address, or * finished a trip earlier, kept seeing a route to it every time they opened * the app. */ routeless?: boolean; // Driver view: override the store-derived origin/destination so the map // centers on the driver's own live position and pins the rider's pickup, // without touching the rider-facing location store. originOverride?: LatLng | null; destinationOverride?: (LatLng & { label?: string }) | null; }; export const Map = ({ trackedDriver, originOverride, destinationOverride, routeless = false, }: MapProps = {}) => { const { userLatitude, userLongitude, destinationLatitude, destinationLongitude, } = useLocationStore(); const { service } = useServiceStore(); const { selectedDriver, setDrivers } = useDriverStore(); const { isDark } = useTheme(); const trackingMode = Boolean(trackedDriver) || originOverride !== undefined || destinationOverride !== undefined; // Online drivers of the selected service near the rider. Falls back to a // Beirut center when the rider's position isn't resolved yet so the map // still populates instead of sitting empty. // // The search starts tight around the rider and widens in 5 km steps only // when it finds nobody, so a rider on a busy street sees the cars actually // near them rather than every car in the country. const lat = userLatitude ?? 33.8938; const lng = userLongitude ?? 35.5018; const { drivers } = useNearbyDrivers(service, lat, lng); const [markers, setMarkers] = useState([]); const mapRef = useRef(null); // Region: in tracking mode, center on the driver's own position (or the // pickup point if that isn't resolved yet) instead of the rider's location // store, which tracking mode never touches. const region = trackingMode ? calculateRegion({ userLatitude: originOverride?.latitude ?? destinationOverride?.latitude ?? null, userLongitude: originOverride?.longitude ?? destinationOverride?.longitude ?? null, destinationLatitude: originOverride ? (destinationOverride?.latitude ?? null) : null, destinationLongitude: originOverride ? (destinationOverride?.longitude ?? null) : null, }) : calculateRegion({ userLatitude, userLongitude, destinationLatitude: routeless ? null : destinationLatitude, destinationLongitude: routeless ? null : destinationLongitude, }); // `initialRegion` is read once, at mount. The map mounts before the location // fix arrives, so it would sit on the Beirut fallback forever and never zoom // out to fit a destination the rider picks later. Animate on every real // change instead. Keyed on the coordinates so the repeated setUserLocation // from reverse geocoding (same coords, new address) doesn't yank the camera // back while the rider is panning. const regionKey = `${region.latitude},${region.longitude},${region.latitudeDelta},${region.longitudeDelta}`; const lastRegionKey = useRef(regionKey); useEffect(() => { if (lastRegionKey.current === regionKey) return; lastRegionKey.current = regionKey; mapRef.current?.animateToRegion(region, 500); // eslint-disable-next-line react-hooks/exhaustive-deps }, [regionKey]); useEffect(() => { if (trackedDriver) { setMarkers( trackedDriver.latitude != null && trackedDriver.longitude != null ? [ { id: trackedDriver.id, latitude: trackedDriver.latitude, longitude: trackedDriver.longitude, title: `${trackedDriver.first_name ?? ""} ${trackedDriver.last_name ?? ""}`.trim(), profile_image_url: trackedDriver.profile_image_url ?? "", car_image_url: trackedDriver.car_image_url ?? "", car_seats: trackedDriver.car_seats ?? 0, rating: trackedDriver.rating ?? 0, first_name: trackedDriver.first_name ?? "", last_name: trackedDriver.last_name ?? "", car_model: trackedDriver.car_model ?? null, service: trackedDriver.service ?? undefined, }, ] : [], ); return; } if (trackingMode) { setMarkers([]); return; } if (Array.isArray(drivers)) { if (!userLatitude || !userLongitude) return; const newMarkers = generateMarkersFromData({ data: drivers, userLatitude, userLongitude, }); setMarkers(newMarkers); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [trackedDriver, trackingMode, drivers, userLatitude, userLongitude]); useEffect(() => { if (trackingMode) return; if (markers.length > 0 && destinationLatitude && destinationLongitude) { calculateDriverTimes({ markers, userLatitude, userLongitude, destinationLatitude, destinationLongitude, service, }).then((driversWithTimes) => { setDrivers((driversWithTimes as MarkerData[]) ?? []); }); } }, [ trackingMode, markers, destinationLatitude, destinationLongitude, userLatitude, userLongitude, setDrivers, service, ]); // The map itself never waits on the driver list or the location fix: drivers // are an overlay, and calculateRegion falls back to Beirut without coords. // Previously either one failing replaced the whole map with a spinner or an // error line, which read as "the map didn't load". return ( {markers.map((marker) => ( ))} {destinationOverride ? ( ) : ( !routeless && userLatitude && userLongitude && destinationLatitude && destinationLongitude && ( <> ) )} ); };