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
+284 -9
View File
@@ -1,11 +1,128 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
// Every control on this screen lives inside the RideLayout bottom sheet, and
// on Android a react-native touchable in there loses its first press to the
// sheet's gesture handler — which is why "Find now" had to be tapped twice to
// send a request. The sheet's own touchables are the fix the library ships for
// this; on iOS they are react-native's, unchanged.
import { TouchableOpacity } from "@gorhom/bottom-sheet";
import { router } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Text, View } from "react-native";
import { CustomButton } from "@/components/custom-button";
import { GoogleTextInput } from "@/components/google-text-input";
import { RideLayout } from "@/components/ride-layout";
import { icons } from "@/constants";
import { SERVICES, type ServiceId } from "@/constants/services";
import { ApiError } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useLocationStore } from "@/store";
import { router } from "expo-router";
import { Text, View } from "react-native";
import { calculateTripFare } from "@/lib/map";
import { formatLBP } from "@/lib/pricing";
import { createRideRequest } from "@/lib/request-ride";
import { useServiceAvailability } from "@/lib/use-service-availability";
import { formatTime } from "@/lib/utils";
import { useLocationStore, useServiceStore } from "@/store";
/**
* "Set it on the map" for one of the two points.
*
* An autocomplete result lands on whatever the geocoder calls the centre of a
* place, which is regularly the wrong side of a building or the wrong end of a
* long street — and a driver sent to the wrong side of a divided road can't
* simply turn around. This is the escape hatch: the rider drags the map to the
* exact doorway.
*/
const AdjustOnMap = ({ mode }: { mode: "origin" | "destination" }) => {
const t = useT();
return (
<TouchableOpacity
onPress={() =>
router.push({ pathname: "/(root)/adjust-pin", params: { mode } })
}
className="flex-row items-center gap-x-2 mt-2 self-start px-1 py-1.5"
>
<MaterialCommunityIcons
name="map-marker-radius"
size={16}
color="#0286ff"
/>
<Text className="text-sm font-JakartaBold text-primary-500">
{t("findRide.adjustOnMap")}
</Text>
</TouchableOpacity>
);
};
/**
* Which service the request goes out on, with live availability.
*
* It lives on this screen because this is now the last screen before drivers
* are contacted — the request is broadcast on tap, so the choice of who to
* broadcast it to has to be made here, next to the button that sends it.
*/
const ServiceRow = ({
service,
counts,
onSelect,
}: {
service: ServiceId;
counts: Record<ServiceId, number>;
onSelect: (id: ServiceId) => void;
}) => {
const t = useT();
return (
<View className="flex-row gap-2">
{SERVICES.map((item) => {
const active = item.id === service;
const available = counts[item.id] ?? 0;
return (
<TouchableOpacity
key={item.id}
onPress={() => onSelect(item.id)}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityState={{ selected: active }}
className={`flex-1 items-center rounded-2xl border py-2.5 ${
active
? "border-primary-500 bg-primary-500/10"
: "border-neutral-100 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
}`}
>
<MaterialCommunityIcons
name={item.icon}
size={20}
color={active ? "#0286ff" : "#858585"}
/>
<Text
className={`text-[11px] mt-1 font-JakartaMedium ${
active
? "text-primary-500"
: "text-general-200 dark:text-neutral-400"
}`}
>
{t(item.labelKey)}
</Text>
{/* The count is the honest version of an empty map: it says
whether asking this service is worth doing before the rider
sends a request nobody will answer. */}
<Text
className={`text-[10px] ${
available > 0
? "text-emerald-600 dark:text-emerald-400"
: "text-general-200 dark:text-neutral-500"
}`}
>
{available > 0 ? t("findRide.nAvailable", { n: available }) : "—"}
</Text>
</TouchableOpacity>
);
})}
</View>
);
};
const FindRide = () => {
const t = useT();
@@ -19,13 +136,126 @@ const FindRide = () => {
setDestinationLocation,
setUserLocation,
} = useLocationStore();
const { service, setService } = useServiceStore();
const canFind =
const [estimate, setEstimate] = useState<{
fare: string;
durationSeconds: number;
} | null>(null);
const [estimating, setEstimating] = useState(false);
const [sending, setSending] = useState(false);
const hasRoute =
!!userLatitude &&
!!userLongitude &&
!!destinationLatitude &&
!!destinationLongitude;
const { counts } = useServiceAvailability(userLatitude, userLongitude);
// The fare is quoted before the request goes out, not after: it is what the
// drivers deciding whether to take the job are shown, so it has to exist by
// the time the request does. Recomputed when the route or service changes.
useEffect(() => {
if (!hasRoute) {
setEstimate(null);
return;
}
let cancelled = false;
setEstimating(true);
void calculateTripFare({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service,
})
.then((trip) => {
if (cancelled) return;
setEstimate(
trip
? { fare: trip.fare, durationSeconds: trip.durationSeconds }
: null,
);
})
.finally(() => {
if (!cancelled) setEstimating(false);
});
return () => {
cancelled = true;
};
}, [
hasRoute,
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service,
]);
const findNow = async () => {
if (!hasRoute || !estimate) return;
setSending(true);
try {
const ride = await createRideRequest({
service,
origin: {
address: userAddress ?? "",
latitude: userLatitude!,
longitude: userLongitude!,
},
destination: {
address: destinationAddress ?? "",
latitude: destinationLatitude!,
longitude: destinationLongitude!,
},
rideTimeSeconds: estimate.durationSeconds,
fareCents: Math.round(parseFloat(estimate.fare) * 100),
});
router.replace(`/(root)/book-ride?id=${ride.ride_id}`);
} catch (err) {
console.log("[FIND_RIDE]: ", err);
// The rider already has a ride in flight. Booking a second one isn't
// what they want — they want the one they lost track of, so take them
// to it instead of showing an error they can't act on.
if (
err instanceof ApiError &&
err.status === 409 &&
err.body?.code === "RIDE_IN_PROGRESS"
) {
const inProgressId = String(err.body.ride_id);
Alert.alert(
t("confirmRide.alertInProgressTitle"),
t("confirmRide.alertInProgressBody"),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("confirmRide.viewRide"),
onPress: () =>
router.replace(`/(root)/book-ride?id=${inProgressId}`),
},
],
);
return;
}
Alert.alert(
t("confirmRide.alertErrorTitle"),
err instanceof ApiError
? err.message
: t("confirmRide.alertErrorFallback"),
);
} finally {
setSending(false);
}
};
return (
<RideLayout title={t("findRide.title")} snapPoints={["85%"]}>
<View className="my-3">
@@ -39,6 +269,8 @@ const FindRide = () => {
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setUserLocation}
/>
<AdjustOnMap mode="origin" />
</View>
<View className="my-3">
@@ -52,16 +284,59 @@ const FindRide = () => {
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setDestinationLocation}
/>
<AdjustOnMap mode="destination" />
</View>
<Text className="text-sm font-JakartaSemiBold mb-2 mt-1 text-black dark:text-white">
{t("findRide.service")}
</Text>
<ServiceRow service={service} counts={counts} onSelect={setService} />
{/* The quote, shown before the request goes out rather than on a screen
after it. This is the number the rider agrees to and the number every
driver who sees the request is offered, so it belongs next to the
button that sends it. */}
<View className="flex-row items-center justify-between rounded-2xl bg-general-500 dark:bg-neutral-950 px-4 py-3 mt-4">
<View>
<Text className="text-xs font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("findRide.estimatedFare")}
</Text>
<Text className="text-[11px] text-general-200 dark:text-neutral-400 mt-0.5">
{estimate
? t("confirmRide.tripTime", {
time: formatTime(estimate.durationSeconds / 60),
})
: t("findRide.setBothPoints")}
</Text>
</View>
<View className="items-end">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{estimating ? "…" : estimate ? `$${estimate.fare}` : "—"}
</Text>
{estimate ? (
<Text className="text-[11px] text-general-200 dark:text-neutral-400">
{t("confirmRide.lbpEstimate", {
lbp: formatLBP(parseFloat(estimate.fare)),
})}
</Text>
) : null}
</View>
</View>
<Text className="text-[11px] text-center text-general-200 dark:text-neutral-400 mt-3">
{t("findRide.payLaterHint")}
</Text>
<CustomButton
title={t("findRide.findNow")}
onPress={() => router.push("/(root)/confirm-ride")}
disabled={!canFind}
className={`mt-5 ${!canFind ? "opacity-50" : ""}`}
Touchable={TouchableOpacity}
title={sending ? t("findRide.sending") : t("findRide.findNow")}
onPress={() => void findNow()}
disabled={!hasRoute || !estimate || estimating || sending}
className={`mt-3 ${!hasRoute || !estimate || estimating || sending ? "opacity-50" : ""}`}
/>
</RideLayout>
);
};
export default FindRide;
export default FindRide;