Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.
Camera permission on Android:
- Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
own plugin never declares CAMERA, and Android denies a request for an
undeclared permission instantly and silently — no dialog is ever shown,
which is indistinguishable from the app not asking at all.
- Handle canAskAgain: once Android stops showing the dialog, repeating why
we need it is a dead end, so offer Open Settings instead (lib/capture-
permission.ts), matching what the location flow already did.
Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.
Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
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<string, unknown>) => {
|
|
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 (
|
|
<MarkerAnimated
|
|
coordinate={animatedCoordinate}
|
|
title={marker.title}
|
|
anchor={{ x: 0.5, y: 0.5 }}
|
|
tracksViewChanges={tracksViewChanges}
|
|
>
|
|
<View style={styles.markerFrame}>
|
|
{/* 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 ? (
|
|
<View
|
|
style={[
|
|
StyleSheet.absoluteFill,
|
|
{ transform: [{ rotate: `${heading}deg` }] },
|
|
styles.markerFrame,
|
|
]}
|
|
>
|
|
<MaterialCommunityIcons
|
|
name="navigation"
|
|
size={14}
|
|
color={selected ? "#0286ff" : "#111827"}
|
|
style={styles.headingArrow}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
|
|
<View
|
|
style={[
|
|
styles.markerBubble,
|
|
selected ? styles.markerBubbleSelected : null,
|
|
]}
|
|
>
|
|
<MaterialCommunityIcons
|
|
name={glyphFor(marker.service)}
|
|
size={18}
|
|
color="#ffffff"
|
|
/>
|
|
</View>
|
|
</View>
|
|
</MarkerAnimated>
|
|
);
|
|
};
|
|
|
|
// "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<MarkerData[]>([]);
|
|
const mapRef = useRef<MapView>(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 (
|
|
<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) => (
|
|
<ServiceMarker
|
|
key={marker.id}
|
|
marker={marker}
|
|
selected={Boolean(trackedDriver) || selectedDriver === marker.id}
|
|
/>
|
|
))}
|
|
|
|
{destinationOverride ? (
|
|
<Marker
|
|
key="pickup"
|
|
coordinate={{
|
|
latitude: destinationOverride.latitude,
|
|
longitude: destinationOverride.longitude,
|
|
}}
|
|
title={destinationOverride.label ?? tr("components.map.destination")}
|
|
image={icons.pin}
|
|
/>
|
|
) : (
|
|
!routeless &&
|
|
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>
|
|
);
|
|
};
|