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
+340
-32
@@ -1,54 +1,99 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
import { CancelSheet } from "@/components/cancel-sheet";
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { Map } from "@/components/map";
|
||||
import { OfferList } from "@/components/offer-list";
|
||||
import { PaymentChoiceSheet } from "@/components/payment-choice-sheet";
|
||||
import { RatingSheet } from "@/components/rating-sheet";
|
||||
import { icons, images } from "@/constants";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { payByCard, selectDriver } from "@/lib/request-ride";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { formatTime } from "@/lib/utils";
|
||||
import { useLocationStore } from "@/store";
|
||||
import type { Ride } from "@/types/type";
|
||||
import type { Ride, RideOffer } from "@/types/type";
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
// While the request is open, offers arrive one driver at a time and the rider
|
||||
// is staring at the list waiting for them. A three-second gap between a driver
|
||||
// tapping Offer and their face appearing reads as nothing happening.
|
||||
const OPEN_POLL_MS = 1500;
|
||||
|
||||
const STATUS_KEY: Record<string, string> = {
|
||||
requested: "bookRide.status.requested",
|
||||
accepted: "bookRide.status.accepted",
|
||||
arrived: "bookRide.status.arrived",
|
||||
en_route: "bookRide.status.enRoute",
|
||||
completed: "bookRide.status.completed",
|
||||
cancelled: "bookRide.status.cancelled",
|
||||
expired: "bookRide.status.expired",
|
||||
};
|
||||
|
||||
const TERMINAL = ["completed", "cancelled", "expired"];
|
||||
|
||||
// book-ride is now the live ride-status screen. The rider lands here after
|
||||
// requesting a ride and polls its status until it completes (or they cancel).
|
||||
const BookRide = () => {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const rideId = Number(id);
|
||||
const t = useT();
|
||||
const { user } = useSession();
|
||||
const setUserLocation = useLocationStore((s) => s.setUserLocation);
|
||||
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
|
||||
const setDestinationLocation = useLocationStore(
|
||||
(s) => s.setDestinationLocation,
|
||||
);
|
||||
const clearDestination = useLocationStore((s) => s.clearDestination);
|
||||
|
||||
const [ride, setRide] = useState<Ride | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
// The offer the rider tapped, held while they choose how to pay.
|
||||
const [picked, setPicked] = useState<RideOffer | null>(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
// A card order that was paid but whose selection then failed. Kept so the
|
||||
// rider can pick a different driver without paying a second time — the
|
||||
// server only consumes an order when a driver is actually assigned.
|
||||
const paidOrder = useRef<string | null>(null);
|
||||
// Server clock minus device clock, so the elapsed counter is measured on the
|
||||
// clock the request window is actually enforced against.
|
||||
const clockOffset = useRef(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
// Set once, when the ride first lands on 'completed' during this session,
|
||||
// so dismissing the sheet doesn't immediately re-open it on the next poll.
|
||||
const [ratingOpen, setRatingOpen] = useState(false);
|
||||
const [ratingHandled, setRatingHandled] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchAPI(`/(api)/ride/${rideId}`);
|
||||
const r = res.data as Ride;
|
||||
if (r.now) clockOffset.current = Date.parse(r.now) - Date.now();
|
||||
setRide(r);
|
||||
|
||||
// Ask for the rating the moment the driver ends the trip — the rider is
|
||||
// still in the car and still remembers. `my_rating` covers the case
|
||||
// where they already rated from the home banner.
|
||||
if (r.status === "completed" && r.my_rating == null && !ratingHandled) {
|
||||
setRatingOpen(true);
|
||||
}
|
||||
|
||||
// Keep the map's origin/destination in sync with the ride so the route
|
||||
// line renders even if the rider reached this screen via history.
|
||||
setUserLocation({
|
||||
@@ -69,28 +114,98 @@ const BookRide = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rideId, setUserLocation, setDestinationLocation, t]);
|
||||
}, [rideId, setUserLocation, setDestinationLocation, ratingHandled, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Poll while the ride is still in a non-terminal state.
|
||||
// Drop the route when the rider leaves this screen.
|
||||
//
|
||||
// Nothing used to clear it, so a destination survived for the life of the
|
||||
// process — and since backgrounding an app doesn't end that process, the
|
||||
// next launch drew a line to a trip that had already finished. Cleared on
|
||||
// unmount rather than on completion because `load` re-sets it on every poll:
|
||||
// clearing while still on screen would just fight the next poll, and the
|
||||
// tracking map would lose the route the rider is watching.
|
||||
useEffect(() => () => clearDestination(), [clearDestination]);
|
||||
|
||||
// Poll while the ride is still in a non-terminal state, quickly while
|
||||
// offers are still coming in.
|
||||
useEffect(() => {
|
||||
const status = ride?.status;
|
||||
if (!status || status === "completed" || status === "cancelled") return;
|
||||
const timer = setInterval(() => void load(), POLL_MS);
|
||||
if (!status || TERMINAL.includes(status)) return;
|
||||
const every = status === "requested" ? OPEN_POLL_MS : POLL_MS;
|
||||
const timer = setInterval(() => void load(), every);
|
||||
return () => clearInterval(timer);
|
||||
}, [ride?.status, load]);
|
||||
|
||||
const cancel = async () => {
|
||||
// Take one of the offers. This is the call that assigns the ride: it pays
|
||||
// (or commits to cash), locks in that driver and releases the others.
|
||||
//
|
||||
// A 409 means the driver was taken while the rider was deciding — a normal
|
||||
// outcome of several riders competing for the same cars, not an error. The
|
||||
// list simply reloads without them, and any card payment already made stays
|
||||
// unspent and is reused for the next pick.
|
||||
const pay = async (method: "cash" | "card") => {
|
||||
const offer = picked;
|
||||
if (!offer || !ride) return;
|
||||
|
||||
setPaying(true);
|
||||
try {
|
||||
let orderId = paidOrder.current ?? undefined;
|
||||
|
||||
if (method === "card" && !orderId) {
|
||||
orderId = await payByCard({
|
||||
ride,
|
||||
user: { name: user?.name ?? "", email: user?.email ?? "" },
|
||||
});
|
||||
paidOrder.current = orderId;
|
||||
}
|
||||
|
||||
await selectDriver({
|
||||
rideId,
|
||||
offerId: offer.offer_id,
|
||||
method,
|
||||
orderId: method === "card" ? orderId : undefined,
|
||||
});
|
||||
|
||||
// Assigned: the money is spent and the ride has a driver.
|
||||
paidOrder.current = null;
|
||||
setPicked(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
console.log("[BOOK_RIDE_SELECT]: ", err);
|
||||
setPicked(null);
|
||||
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
Alert.alert(
|
||||
t("bookRide.offers.goneTitle"),
|
||||
paidOrder.current
|
||||
? t("bookRide.offers.goneBodyPaid")
|
||||
: t("bookRide.offers.goneBody"),
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("bookRide.alertErrorTitle"),
|
||||
err instanceof ApiError ? err.message : t("bookRide.match.alertBody"),
|
||||
);
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async (reason: string) => {
|
||||
setCancelling(true);
|
||||
try {
|
||||
await fetchAPI(`/(api)/ride/${rideId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "cancelled" }),
|
||||
body: JSON.stringify({ status: "cancelled", reason }),
|
||||
});
|
||||
setCancelOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
console.log("[BOOK_RIDE_CANCEL]: ", err);
|
||||
@@ -124,35 +239,127 @@ const BookRide = () => {
|
||||
}
|
||||
|
||||
const driver = ride.driver;
|
||||
const terminal = ride.status === "completed" || ride.status === "cancelled";
|
||||
const driverId = driver.id;
|
||||
const terminal = TERMINAL.includes(ride.status);
|
||||
const driverName = [driver.first_name, driver.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const cashDue = ride.payment_status === "cash";
|
||||
const offers = (ride.offers ?? []) as RideOffer[];
|
||||
// Whole seconds the search has been running, measured on the server's clock.
|
||||
const searchSeconds = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(Date.now() + clockOffset.current - Date.parse(ride.created_at)) / 1000,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
|
||||
<View className="h-[45%] bg-blue-500">
|
||||
<Map />
|
||||
<Map trackedDriver={driverId ? { ...driver, id: driverId } : null} />
|
||||
</View>
|
||||
|
||||
<View className="flex-1 px-5 pt-4">
|
||||
{/* Scrollable, because the number of things below the map isn't fixed:
|
||||
four drivers offering on a request push the fare, the cancel button
|
||||
— and the fourth driver — off the bottom of the screen, and a rider
|
||||
who can't reach an offer can't take it. */}
|
||||
<ScrollView
|
||||
className="flex-1 px-5 pt-4"
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
|
||||
>
|
||||
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
||||
{STATUS_KEY[ride.status] ? t(STATUS_KEY[ride.status]) : ride.status}
|
||||
{/* Once drivers have volunteered the screen stops being a search and
|
||||
becomes a decision, and the heading has to say which one it is —
|
||||
a rider reading "finding your driver" over a list of drivers
|
||||
doesn't know it's waiting on them. */}
|
||||
{ride.status === "requested" && offers.length > 0
|
||||
? t("bookRide.status.choosing")
|
||||
: STATUS_KEY[ride.status]
|
||||
? t(STATUS_KEY[ride.status])
|
||||
: ride.status}
|
||||
</Text>
|
||||
|
||||
{/* Searching state */}
|
||||
{ride.status === "requested" ? (
|
||||
<View className="items-center mt-6">
|
||||
{/* Waiting on the first driver to volunteer. The elapsed counter is
|
||||
there because a spinner with no number on it reads as broken after
|
||||
about ten seconds — and the request legitimately sits open for a
|
||||
couple of minutes. A rider who can see it counting knows their
|
||||
request is still live. */}
|
||||
{ride.status === "requested" && offers.length === 0 ? (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5 mt-2 items-center">
|
||||
<ActivityIndicator size="large" color="#0286ff" />
|
||||
<Text className="text-general-200 dark:text-neutral-400 mt-3 text-center">
|
||||
{t("bookRide.matchingDriver", { service: ride.service })}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400 mt-2">
|
||||
{t("bookRide.searchingFor", { seconds: searchSeconds })}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Driver card — shown once a driver is assigned. */}
|
||||
{driver?.id ? (
|
||||
{/* Drivers who want the job. The rider picks; everyone else is let go
|
||||
the moment they do. */}
|
||||
{ride.status === "requested" && offers.length > 0 ? (
|
||||
<OfferList
|
||||
offers={offers}
|
||||
pendingOfferId={paying ? (picked?.offer_id ?? null) : null}
|
||||
busy={paying}
|
||||
onPick={setPicked}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Pickup code — the rider's half of the handshake. Shown from the
|
||||
moment a driver is assigned until the trip starts; the driver
|
||||
can't start without hearing it, which is what stops a rider from
|
||||
getting into the wrong car (and the wrong car from taking them). */}
|
||||
{ride.pickup_code ? (
|
||||
<View
|
||||
className={`rounded-2xl p-4 mt-2 items-center ${
|
||||
ride.status === "arrived"
|
||||
? "bg-emerald-500"
|
||||
: "bg-white dark:bg-neutral-900"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-JakartaMedium ${
|
||||
ride.status === "arrived"
|
||||
? "text-white/90"
|
||||
: "text-general-200 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{ride.status === "arrived"
|
||||
? t("bookRide.driverHere")
|
||||
: t("bookRide.pickupCodeLabel")}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-4xl font-JakartaExtraBold tracking-[8px] mt-1 ${
|
||||
ride.status === "arrived"
|
||||
? "text-white"
|
||||
: "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{ride.pickup_code}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xs text-center mt-1 ${
|
||||
ride.status === "arrived"
|
||||
? "text-white/90"
|
||||
: "text-general-200 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{t("bookRide.pickupCodeHint")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Driver card — shown once the pairing is confirmed. While the ride
|
||||
is still 'matched' the confirmation card above is showing the same
|
||||
driver, and two cards for one driver reads as two drivers. */}
|
||||
{driver?.id && ride.status !== "matched" ? (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-2">
|
||||
<View className="flex-row items-center">
|
||||
<Image
|
||||
source={{ uri: driver.profile_image_url ?? undefined }}
|
||||
source={{ uri: driverPhotoUri(driver.profile_image_url) }}
|
||||
className="w-16 h-16 rounded-full"
|
||||
/>
|
||||
<View className="ml-4 flex-1">
|
||||
@@ -171,20 +378,48 @@ const BookRide = () => {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize">
|
||||
{driver.service ?? ride.service}
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize mr-3">
|
||||
{driver.service ?? ride.service}
|
||||
</Text>
|
||||
{/* Call the driver — only while the ride is active. */}
|
||||
{!terminal ? (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(root)/call",
|
||||
params: { rideId: String(ride.ride_id), mode: "start" },
|
||||
})
|
||||
}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
accessibilityLabel={t("chat.call")}
|
||||
className="w-9 h-9 rounded-full bg-general-400 items-center justify-center"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="phone"
|
||||
size={18}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mt-4">
|
||||
<Image source={icons.to} className="w-4 h-4" />
|
||||
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
|
||||
<Text
|
||||
className="font-JakartaMedium text-sm text-black dark:text-white"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{ride.origin_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-x-2 mt-2">
|
||||
<Image source={icons.point} className="w-4 h-4" />
|
||||
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
|
||||
<Text
|
||||
className="font-JakartaMedium text-sm text-black dark:text-white"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{ride.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -212,14 +447,41 @@ const BookRide = () => {
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-1">
|
||||
{t("bookRide.tripTime", { time: formatTime(ride.ride_time) })}
|
||||
</Text>
|
||||
{/* A cash ride the driver hasn't marked collected is money still
|
||||
owed — say so rather than showing a clean "all done". */}
|
||||
{cashDue ? (
|
||||
<Text className="text-amber-600 dark:text-amber-400 text-sm mt-2 text-center">
|
||||
{t("bookRide.cashDue", {
|
||||
amount: (ride.fare_price / 100).toFixed(2),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{ride.my_rating ? (
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-2">
|
||||
{t("bookRide.youRated", { n: ride.my_rating })}
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => setRatingOpen(true)}
|
||||
className="mt-3"
|
||||
>
|
||||
<Text className="font-JakartaBold text-primary-500">
|
||||
{t("bookRide.rateDriver")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Cancelled */}
|
||||
{ride.status === "cancelled" ? (
|
||||
{/* Cancelled / expired */}
|
||||
{ride.status === "cancelled" || ride.status === "expired" ? (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
|
||||
<Text className="text-general-200 dark:text-neutral-400">
|
||||
{t("bookRide.rideCancelled")}
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-center">
|
||||
{ride.status === "expired"
|
||||
? t("bookRide.noDriversFound")
|
||||
: ride.cancelled_by === "driver"
|
||||
? t("bookRide.cancelledByDriver")
|
||||
: t("bookRide.rideCancelled")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -230,21 +492,67 @@ const BookRide = () => {
|
||||
title={t("bookRide.backHome")}
|
||||
onPress={() => router.replace("/(root)/(tabs)/home")}
|
||||
/>
|
||||
) : ride.status === "en_route" ? (
|
||||
// Once the trip is under way there is nothing to cancel — the
|
||||
// rider is in the car. Ending it early is the driver's action.
|
||||
<Text className="text-center text-general-200 dark:text-neutral-400 text-sm pb-3">
|
||||
{t("bookRide.enRouteNotice")}
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={cancel}
|
||||
onPress={() => setCancelOpen(true)}
|
||||
disabled={cancelling}
|
||||
className="rounded-full py-3 bg-white dark:bg-neutral-900 items-center border border-rose-300 dark:border-rose-900"
|
||||
>
|
||||
<Text className="font-JakartaBold text-rose-500">
|
||||
{cancelling ? t("bookRide.cancelling") : t("bookRide.cancelRide")}
|
||||
{cancelling
|
||||
? t("bookRide.cancelling")
|
||||
: t("bookRide.cancelRide")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<PaymentChoiceSheet
|
||||
visible={picked !== null}
|
||||
driverName={
|
||||
picked
|
||||
? [picked.first_name, picked.last_name].filter(Boolean).join(" ")
|
||||
: null
|
||||
}
|
||||
fareCents={ride.fare_price}
|
||||
submitting={paying}
|
||||
onPay={(method) => void pay(method)}
|
||||
onCancel={() => setPicked(null)}
|
||||
/>
|
||||
|
||||
<CancelSheet
|
||||
visible={cancelOpen}
|
||||
audience="rider"
|
||||
submitting={cancelling}
|
||||
onCancel={() => setCancelOpen(false)}
|
||||
onConfirm={(reason) => void cancel(reason)}
|
||||
/>
|
||||
|
||||
<RatingSheet
|
||||
visible={ratingOpen}
|
||||
rideId={rideId}
|
||||
audience="rider"
|
||||
subjectName={driverName || null}
|
||||
subjectAvatar={driver.profile_image_url}
|
||||
onDone={() => {
|
||||
setRatingOpen(false);
|
||||
setRatingHandled(true);
|
||||
void load();
|
||||
}}
|
||||
onSkip={() => {
|
||||
setRatingOpen(false);
|
||||
setRatingHandled(true);
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookRide;
|
||||
export default BookRide;
|
||||
|
||||
Reference in New Issue
Block a user