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
+284
-9
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user