Waseel: driver capture, chat/calls, dispatch, and session fixes
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
+310
-27
@@ -1,10 +1,16 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform, StyleSheet } from "react-native";
|
||||
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
|
||||
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 { useFetch } from "@/lib/fetch";
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { tr } from "@/lib/i18n";
|
||||
import {
|
||||
calculateDriverTimes,
|
||||
@@ -12,15 +18,186 @@ import {
|
||||
generateMarkersFromData,
|
||||
} from "@/lib/map";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import { useNearbyDrivers } from "@/lib/use-nearby-drivers";
|
||||
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
|
||||
import type { Driver, MarkerData } from "@/types/type";
|
||||
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.
|
||||
@@ -41,7 +218,52 @@ const MUTED_POI_STYLE = [
|
||||
},
|
||||
];
|
||||
|
||||
export const Map = () => {
|
||||
// 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,
|
||||
@@ -52,24 +274,47 @@ export const Map = () => {
|
||||
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 { data: drivers, error } = useFetch<Driver[]>(
|
||||
`/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`,
|
||||
);
|
||||
const { drivers } = useNearbyDrivers(service, lat, lng);
|
||||
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
const region = calculateRegion({
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
});
|
||||
// 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
|
||||
@@ -89,6 +334,36 @@ export const Map = () => {
|
||||
}, [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;
|
||||
|
||||
@@ -101,9 +376,10 @@ export const Map = () => {
|
||||
setMarkers(newMarkers);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drivers, userLatitude, userLongitude]);
|
||||
}, [trackedDriver, trackingMode, drivers, userLatitude, userLongitude]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackingMode) return;
|
||||
if (markers.length > 0 && destinationLatitude && destinationLongitude) {
|
||||
calculateDriverTimes({
|
||||
markers,
|
||||
@@ -117,6 +393,7 @@ export const Map = () => {
|
||||
});
|
||||
}
|
||||
}, [
|
||||
trackingMode,
|
||||
markers,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
@@ -130,7 +407,6 @@ export const Map = () => {
|
||||
// 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
|
||||
@@ -146,20 +422,26 @@ export const Map = () => {
|
||||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||||
>
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
<ServiceMarker
|
||||
key={marker.id}
|
||||
coordinate={{
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
}}
|
||||
title={marker.title}
|
||||
image={
|
||||
selectedDriver === marker.id ? icons.selectedMarker : icons.marker
|
||||
}
|
||||
marker={marker}
|
||||
selected={Boolean(trackedDriver) || selectedDriver === marker.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{userLatitude &&
|
||||
{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 && (
|
||||
@@ -188,7 +470,8 @@ export const Map = () => {
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</MapView>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user