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>
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
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 { 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();
|
|
const {
|
|
userAddress,
|
|
destinationAddress,
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
setDestinationLocation,
|
|
setUserLocation,
|
|
} = useLocationStore();
|
|
const { service, setService } = useServiceStore();
|
|
|
|
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">
|
|
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
|
{t("findRide.from")}
|
|
</Text>
|
|
|
|
<GoogleTextInput
|
|
icon={icons.target}
|
|
initialLocation={userAddress ?? ""}
|
|
containerStyles="bg-neutral-100 dark:bg-neutral-800"
|
|
handlePress={setUserLocation}
|
|
/>
|
|
|
|
<AdjustOnMap mode="origin" />
|
|
</View>
|
|
|
|
<View className="my-3">
|
|
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
|
{t("findRide.to")}
|
|
</Text>
|
|
|
|
<GoogleTextInput
|
|
icon={icons.map}
|
|
initialLocation={destinationAddress ?? ""}
|
|
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
|
|
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;
|