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:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+310 -27
View File
@@ -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>
);
};