195 lines
6.0 KiB
TypeScript
195 lines
6.0 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Platform, StyleSheet } from "react-native";
|
|
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
|
|
import MapViewDirections from "react-native-maps-directions";
|
|
|
|
import { icons } from "@/constants";
|
|
import { useFetch } from "@/lib/fetch";
|
|
import { tr } from "@/lib/i18n";
|
|
import {
|
|
calculateDriverTimes,
|
|
calculateRegion,
|
|
generateMarkersFromData,
|
|
} from "@/lib/map";
|
|
import { useTheme } from "@/lib/theme";
|
|
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
|
|
import type { Driver, 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 },
|
|
});
|
|
|
|
// "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" }],
|
|
},
|
|
];
|
|
|
|
export const Map = () => {
|
|
const {
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
} = useLocationStore();
|
|
const { service } = useServiceStore();
|
|
const { selectedDriver, setDrivers } = useDriverStore();
|
|
const { isDark } = useTheme();
|
|
|
|
// 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.
|
|
const lat = userLatitude ?? 33.8938;
|
|
const lng = userLongitude ?? 35.5018;
|
|
const { data: drivers, error } = useFetch<Driver[]>(
|
|
`/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`,
|
|
);
|
|
|
|
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
|
const mapRef = useRef<MapView>(null);
|
|
|
|
const region = calculateRegion({
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
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 (Array.isArray(drivers)) {
|
|
if (!userLatitude || !userLongitude) return;
|
|
|
|
const newMarkers = generateMarkersFromData({
|
|
data: drivers,
|
|
userLatitude,
|
|
userLongitude,
|
|
});
|
|
|
|
setMarkers(newMarkers);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [drivers, userLatitude, userLongitude]);
|
|
|
|
useEffect(() => {
|
|
if (markers.length > 0 && destinationLatitude && destinationLongitude) {
|
|
calculateDriverTimes({
|
|
markers,
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
service,
|
|
}).then((driversWithTimes) => {
|
|
setDrivers((driversWithTimes as MarkerData[]) ?? []);
|
|
});
|
|
}
|
|
}, [
|
|
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".
|
|
if (error) console.log("[MAP_DRIVERS]: ", error);
|
|
|
|
return (
|
|
<MapView
|
|
ref={mapRef}
|
|
provider={PROVIDER_DEFAULT}
|
|
style={styles.map}
|
|
tintColor={isDark ? "white" : "black"}
|
|
mapType={MAP_TYPE}
|
|
customMapStyle={MUTED_POI_STYLE}
|
|
showsPointsOfInterest={false}
|
|
initialRegion={region}
|
|
showsUserLocation
|
|
userInterfaceStyle={isDark ? "dark" : "light"}
|
|
>
|
|
{markers.map((marker) => (
|
|
<Marker
|
|
key={marker.id}
|
|
coordinate={{
|
|
latitude: marker.latitude,
|
|
longitude: marker.longitude,
|
|
}}
|
|
title={marker.title}
|
|
image={
|
|
selectedDriver === marker.id ? icons.selectedMarker : icons.marker
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{userLatitude &&
|
|
userLongitude &&
|
|
destinationLatitude &&
|
|
destinationLongitude && (
|
|
<>
|
|
<Marker
|
|
key="destination"
|
|
coordinate={{
|
|
latitude: destinationLatitude,
|
|
longitude: destinationLongitude,
|
|
}}
|
|
title={tr("components.map.destination")}
|
|
image={icons.pin}
|
|
/>
|
|
|
|
<MapViewDirections
|
|
origin={{
|
|
latitude: userLatitude,
|
|
longitude: userLongitude,
|
|
}}
|
|
destination={{
|
|
latitude: destinationLatitude,
|
|
longitude: destinationLongitude,
|
|
}}
|
|
apikey={process.env.EXPO_PUBLIC_GOOGLE_API_KEY!}
|
|
strokeColor="#0286FF"
|
|
strokeWidth={3}
|
|
/>
|
|
</>
|
|
)}
|
|
</MapView>
|
|
);
|
|
};
|