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
+7
View File
@@ -63,6 +63,13 @@ const TabsLayout = () => {
tabBarActiveTintColor: "white",
tabBarInactiveTintColor: "white",
tabBarShowLabel: false,
// Get out of the way while someone is typing. The bar floats
// (position: absolute) and Android resizes the window around the
// keyboard, so it doesn't stay at the bottom of the screen — it rides up
// and parks on top of the address suggestions the rider is trying to
// tap, which is the worst possible place for it during a pickup or
// destination search.
tabBarHideOnKeyboard: true,
tabBarStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#333",
borderRadius: 50,
+8 -35
View File
@@ -1,38 +1,11 @@
import { Image, ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ChatThread } from "@/components/chat-thread";
import { images } from "@/constants";
import { useT } from "@/lib/i18n";
// Tab-bar footprint: 78px tall + 20px bottom margin (see (tabs)/_layout.tsx).
// It's position:"absolute" so it reserves no layout space of its own — the
// composer below needs this much extra clearance or the floating pill bar
// sits on top of it.
const TAB_BAR_CLEARANCE = 98;
const Chat = () => {
const t = useT();
const Chat = () => <ChatThread tabBarClearance={TAB_BAR_CLEARANCE} />;
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 p-5">
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{t("chat.title")}
</Text>
<View className="flex-1 h-fit flex justify-center items-center">
<Image
source={images.message}
alt={t("chat.messageAlt")}
className="w-full h-40"
resizeMode="contain"
/>
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
{t("chat.noMessages")}
</Text>
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
{t("chat.startConversation")}
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default Chat;
export default Chat;
+10 -1
View File
@@ -9,6 +9,7 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ActiveRideBanner } from "@/components/active-ride-banner";
import { GoogleTextInput } from "@/components/google-text-input";
import { LocationNotice } from "@/components/location-notice";
import { Map } from "@/components/map";
@@ -28,6 +29,7 @@ const Home = () => {
const setDestinationLocation = useLocationStore(
(state) => state.setDestinationLocation,
);
const clearDestination = useLocationStore((state) => state.clearDestination);
const { signOut, user } = useSession();
const { isDark } = useTheme();
const t = useT();
@@ -36,6 +38,9 @@ const Home = () => {
const { status: locationStatus, retry: retryLocation } = useUserLocation();
const handleSignOut = () => {
// A different person signing in on this phone must not inherit the last
// rider's destination — the store lives in the JS process, not the session.
clearDestination();
signOut();
router.replace("/(auth)/sign-in");
@@ -103,6 +108,10 @@ const Home = () => {
</View>
</View>
{/* Unfinished ride or unrated trip — the way back into a ride the
rider navigated away from. */}
<ActiveRideBanner />
<GoogleTextInput
icon={icons.search}
containerStyles="bg-white dark:bg-neutral-900 shadow-md shadow-neutral-300 dark:shadow-neutral-950/40"
@@ -118,7 +127,7 @@ const Home = () => {
<>
{/* The map draws straight away on the Beirut fallback so the
slot never sits empty while the fix is still coming. */}
<Map />
<Map routeless />
{locationStatus === "pending" ? (
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 dark:bg-neutral-900/95 px-4 py-2 shadow-md shadow-neutral-400/40 dark:shadow-neutral-950/40">
+113 -138
View File
@@ -1,8 +1,15 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useFocusEffect } from "expo-router";
import { Alert, Linking, Platform, ScrollView, Text, View } from "react-native";
import {
Alert,
Linking,
Platform,
ScrollView,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useCallback, useState } from "react";
import { Children, Fragment, useCallback, useState } from "react";
import { SettingsRow } from "@/components/settings-row";
import {
@@ -12,7 +19,6 @@ import {
} from "@/lib/settings";
import { useT } from "@/lib/i18n";
import { useLocationPermission } from "@/lib/use-location-permission";
import { useTheme } from "@/lib/theme";
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
@@ -22,15 +28,45 @@ const SectionHeader = ({ title }: { title: string }) => (
</Text>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{children}
</View>
);
/**
* A grouped settings card. Renders an optional muted description header, then
* its children with an automatic divider between each row — so callers never
* hand-thread `border-t` wrapper Views. Null/conditional children (and arrays
* from `.map`) are flattened by `Children.toArray`, so conditionals like
* `status !== "granted" ? <Row/> : null` and `options.map(...)` both work.
*/
const SettingsCard = ({
description,
children,
}: {
description?: string;
children: React.ReactNode;
}) => {
const rows = Children.toArray(children);
return (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{description ? (
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{description}
</Text>
</View>
) : null}
{rows.map((row, index) => (
<Fragment key={index}>
{index > 0 ? (
<View className="border-t border-neutral-100 dark:border-neutral-800" />
) : null}
{row}
</Fragment>
))}
</View>
);
};
const Settings = () => {
const t = useT();
const { isDark } = useTheme();
const mode = useSettingsStore((state) => state.mode);
const setMode = useSettingsStore((state) => state.setMode);
@@ -63,20 +99,6 @@ const Settings = () => {
? t("settings.maps.statusBlocked")
: t("settings.maps.statusUnknown");
const modeLabel =
mode === "light"
? t("settings.appearance.light")
: mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system");
const langLabel =
lang === "en"
? t("settings.language.en")
: lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr");
const callEmergency = useCallback(async () => {
try {
await Linking.openURL("tel:112");
@@ -158,7 +180,7 @@ const Settings = () => {
{/* 1. Maps & Navigation */}
<SectionHeader title={t("settings.maps.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="map-marker-radius"
title={t("settings.maps.title")}
@@ -167,60 +189,39 @@ const Settings = () => {
value={locationStatusLabel}
/>
{status !== "granted" ? (
<View className="border-t border-neutral-100 dark:border-neutral-800">
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
</View>
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
) : null}
</Card>
</SettingsCard>
{/* 2. Appearance */}
<SectionHeader title={t("settings.appearance.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.appearance.description")}
</Text>
</View>
{appearanceOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.appearance.description")}>
{appearanceOptions.map((option) => (
<SettingsRow
key={option.mode}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
>
<SettingsRow
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
right="value"
value={
mode === option.mode
? isDark
? "✓"
: "✓"
: ""
}
onPress={() => setMode(option.mode)}
/>
</View>
right="check"
selected={mode === option.mode}
onPress={() => setMode(option.mode)}
/>
))}
</Card>
</SettingsCard>
{/* 3. Safety */}
<SectionHeader title={t("settings.safety.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="phone-in-talk"
title={t("settings.safety.call112")}
@@ -230,64 +231,45 @@ const Settings = () => {
onPress={callEmergency}
/>
{safetyTiles.map((tile) => (
<View
<SettingsRow
key={tile.key}
className="border-t border-neutral-100 dark:border-neutral-800"
>
<SettingsRow
icon={tile.icon}
title={tile.title}
subtitle={
expandedSafety === tile.key ? undefined : tile.body
}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
</View>
icon={tile.icon}
title={tile.title}
subtitle={expandedSafety === tile.key ? undefined : tile.body}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
))}
</Card>
</SettingsCard>
{/* 4. Language */}
<SectionHeader title={t("settings.language.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.language.description")}
</Text>
</View>
{languageOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.language.description")}>
{languageOptions.map((option) => (
<SettingsRow
key={option.lang}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
>
<SettingsRow
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
right="value"
value={lang === option.lang ? "✓" : ""}
onPress={() => chooseLanguage(option.lang)}
/>
</View>
right="check"
selected={lang === option.lang}
onPress={() => chooseLanguage(option.lang)}
/>
))}
</Card>
</SettingsCard>
{/* 5. Keep awake */}
<SectionHeader title={t("settings.keepAwake.title")} />
<Card>
{/* 5. General — keep-awake toggle + (Android) display-over-other-apps */}
<SectionHeader title={t("settings.general.title")} />
<SettingsCard>
<SettingsRow
icon="monitor"
title={t("settings.keepAwake.title")}
@@ -296,27 +278,20 @@ const Settings = () => {
switchValue={keepAwake}
onSwitchChange={setKeepAwake}
/>
</Card>
{/* 6. Display over other apps (Android only) */}
{Platform.OS === "android" ? (
<>
<SectionHeader title={t("settings.overlay.title")} />
<Card>
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
</Card>
</>
) : null}
{Platform.OS === "android" ? (
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
) : null}
</SettingsCard>
</ScrollView>
</SafeAreaView>
);
+42 -12
View File
@@ -1,18 +1,48 @@
import { Stack } from "expo-router";
import { Redirect, Stack } from "expo-router";
import CallWatcher from "@/components/call-watcher";
import { useSession } from "@/lib/session";
const RootLayout = () => {
const { isLoaded, isSignedIn } = useSession();
// Everything under (root) is behind the session, so the check belongs here
// rather than in each screen. app/index.tsx only guards the way in, which
// left a session that ended *while* a screen was open with nowhere to go:
// the screen stayed mounted and kept polling with a token the server had
// already rejected.
//
// Sign-in, not welcome: someone who reaches this point had an account a
// moment ago, and the onboarding carousel is not what they need.
if (!isLoaded) return null;
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />;
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
<Stack.Screen name="confirm-ride" options={{ headerShown: false }} />
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
<Stack.Screen name="role" options={{ headerShown: false }} />
<Stack.Screen
name="driver-home"
options={{ headerShown: false, gestureEnabled: false }}
/>
</Stack>
<>
{/* Watches for incoming WebRTC calls on the active ride and routes the
user to the call screen regardless of which tab is open. No UI. */}
<CallWatcher />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
<Stack.Screen name="adjust-pin" options={{ headerShown: false }} />
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
<Stack.Screen name="role" options={{ headerShown: false }} />
<Stack.Screen
name="driver-home"
options={{ headerShown: false, gestureEnabled: false }}
/>
<Stack.Screen name="driver-chat" options={{ headerShown: false }} />
<Stack.Screen
name="call"
options={{
headerShown: false,
presentation: "fullScreenModal",
gestureEnabled: false,
}}
/>
</Stack>
</>
);
};
+199
View File
@@ -0,0 +1,199 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import * as Location from "expo-location";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CustomButton } from "@/components/custom-button";
import { PinAdjuster } from "@/components/pin-adjuster";
import { useT } from "@/lib/i18n";
import { addressForCoords } from "@/lib/reverse-geocode";
import { useLocationStore } from "@/store";
// "Move the pin to where you actually are."
//
// An address from autocomplete lands on whatever the geocoder considers the
// centre of that place — which can be the wrong side of a building, the wrong
// end of a long street, or the middle of a junction the driver can't stop in.
// The rider knows the doorway; this screen lets them say so, for the pickup
// and the drop-off alike.
//
// Reverse geocoding is debounced rather than run on every frame of the pan:
// the label only has to be right once the map stops.
const GEOCODE_DEBOUNCE_MS = 450;
// Falls back to Beirut, matching the map's own default, so the screen always
// has somewhere to open even before a fix arrives.
const FALLBACK = { latitude: 33.8938, longitude: 35.5018 };
type Coords = { latitude: number; longitude: number };
const AdjustPin = () => {
const t = useT();
const params = useLocalSearchParams<{ mode?: string }>();
const mode = params.mode === "destination" ? "destination" : "origin";
const {
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
setUserLocation,
setDestinationLocation,
} = useLocationStore();
// Open on the point being edited. A destination that hasn't been chosen yet
// starts at the rider instead of an arbitrary city centre, because the place
// they're going is usually near the place they are.
const initial: Coords =
mode === "origin"
? {
latitude: userLatitude ?? FALLBACK.latitude,
longitude: userLongitude ?? FALLBACK.longitude,
}
: {
latitude: destinationLatitude ?? userLatitude ?? FALLBACK.latitude,
longitude:
destinationLongitude ?? userLongitude ?? FALLBACK.longitude,
};
const [coords, setCoords] = useState<Coords>(initial);
const [address, setAddress] = useState<string | null>(null);
const [resolving, setResolving] = useState(true);
const debounce = useRef<ReturnType<typeof setTimeout>>();
const resolve = useCallback((next: Coords) => {
setCoords(next);
clearTimeout(debounce.current);
debounce.current = setTimeout(async () => {
const label = await addressForCoords(next.latitude, next.longitude);
setAddress(label);
setResolving(false);
}, GEOCODE_DEBOUNCE_MS);
}, []);
// Label the point the screen opened on, so the card isn't blank on arrival.
useEffect(() => {
resolve(initial);
return () => clearTimeout(debounce.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const confirm = () => {
const payload = {
latitude: coords.latitude,
longitude: coords.longitude,
address: address ?? t("common.yourLocation"),
};
if (mode === "origin") setUserLocation(payload);
else setDestinationLocation(payload);
router.back();
};
// Jump back to the rider's own position — the usual reason to open this
// screen is that the suggested pickup drifted away from where they're
// standing.
const recenter = async () => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") return;
const position = await Location.getLastKnownPositionAsync({
maxAge: 60_000,
});
if (!position) return;
setResolving(true);
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
} catch (error) {
console.log("[ADJUST_PIN_RECENTER]: ", error);
}
};
return (
<View className="flex-1 bg-white dark:bg-neutral-950">
<PinAdjuster
initial={initial}
onMoveStart={() => setResolving(true)}
onSettled={resolve}
/>
<SafeAreaView className="flex-1" pointerEvents="box-none">
<View className="px-5 pt-2" pointerEvents="box-none">
<TouchableOpacity
onPress={() => router.back()}
accessibilityLabel={t("common.back")}
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
>
<MaterialCommunityIcons name="arrow-left" size={20} color="#0286ff" />
</TouchableOpacity>
</View>
<View className="flex-1" pointerEvents="none" />
<View className="px-5 pb-5" pointerEvents="box-none">
<TouchableOpacity
onPress={recenter}
accessibilityLabel={t("adjustPin.recenter")}
className="self-end mb-3 w-11 h-11 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
>
<MaterialCommunityIcons
name="crosshairs-gps"
size={20}
color="#0286ff"
/>
</TouchableOpacity>
<View className="rounded-2xl bg-white dark:bg-neutral-900 p-5 shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
<Text className="text-xs font-JakartaSemiBold uppercase tracking-wide text-general-200 dark:text-neutral-500 mb-1">
{mode === "origin"
? t("adjustPin.pickupLabel")
: t("adjustPin.destinationLabel")}
</Text>
<View className="flex-row items-center min-h-[26px] mb-1">
{resolving ? (
<>
<ActivityIndicator size="small" color="#0286ff" />
<Text className="ml-2 font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("adjustPin.locating")}
</Text>
</>
) : (
<Text
className="font-JakartaBold text-black dark:text-white text-base flex-1"
numberOfLines={2}
>
{address}
</Text>
)}
</View>
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-4">
{t("adjustPin.hint")}
</Text>
<CustomButton
title={
mode === "origin"
? t("adjustPin.confirmPickup")
: t("adjustPin.confirmDestination")
}
onPress={confirm}
disabled={resolving}
/>
</View>
</View>
</SafeAreaView>
</View>
);
};
export default AdjustPin;
+340 -32
View File
@@ -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;
+228
View File
@@ -0,0 +1,228 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { RTCView } from "react-native-webrtc";
import { fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useCall } from "@/lib/use-call";
import type { ChatActiveRide } from "@/types/type";
// In-app WebRTC audio call screen. Two entry modes:
// mode=start — caller opened this from the chat header; we place the call.
// mode=incoming — CallWatcher detected a ringing call; we attach and wait
// for the user to Accept/Decline.
// Either way the authoritative ride/role/peer come from GET /(api)/chat/active
// (so a stale nav param never dials the wrong ride).
const Call = () => {
const t = useT();
const params = useLocalSearchParams<{
rideId?: string;
role?: "rider" | "driver";
mode?: "start" | "incoming";
}>();
const [active, setActive] = useState<ChatActiveRide | null>(null);
const [resolving, setResolving] = useState(true);
const call = useCall();
const startedRef = useRef(false);
// Resolve the active ride + peer once, then kick off the right flow.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetchAPI("/(api)/chat/active");
const a = (res.data ?? null) as ChatActiveRide | null;
if (cancelled) return;
setActive(a);
if (!a) return;
if (startedRef.current) return;
startedRef.current = true;
const peerName = a.peer?.name ?? "";
if (params.mode === "start") {
void call.startCall(a.ride_id, a.role, peerName);
} else {
call.watch(a.ride_id, a.role, peerName);
}
} catch (err) {
console.log("[CALL_SCREEN_RESOLVE]: ", err);
} finally {
if (!cancelled) setResolving(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Surface a mic-permission denial and back out.
useEffect(() => {
if (call.micError) {
Alert.alert(t("call.micDeniedTitle"), t("call.micDeniedBody"), [
{ text: "OK", onPress: () => router.back() },
]);
}
}, [call.micError, t]);
// When the call reaches a terminal state, show the label briefly, then
// leave the screen so the user returns to where they came from.
useEffect(() => {
if (call.status !== "ended") return;
const timer = setTimeout(() => router.back(), 1200);
return () => clearTimeout(timer);
}, [call.status]);
const peerName = active?.peer?.name ?? call.peerName ?? "";
const handleEnd = useCallback(() => {
void call.endCall();
}, [call]);
const handleAccept = useCallback(() => {
void call.answerCall();
}, [call]);
const handleDecline = useCallback(() => {
void call.declineCall();
}, [call]);
if (resolving) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<Text className="text-general-200 dark:text-neutral-400">
{t("call.connecting")}
</Text>
</SafeAreaView>
);
}
if (!active) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center px-7">
<Text className="text-base text-center text-general-200 dark:text-neutral-400">
{t("call.unavailable")}
</Text>
<TouchableOpacity
onPress={() => router.back()}
className="mt-6 px-6 py-3 rounded-full bg-general-400"
>
<Text className="text-white font-JakartaBold">
{t("call.cancel")}
</Text>
</TouchableOpacity>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-between py-10">
{/* Audio sink — hidden; keeps the native audio pipeline attached even
though this is an audio-only call (RTCView is the stream sink). */}
{call.remoteStream ? (
<RTCView
streamURL={call.remoteStream.toURL()}
className="w-1 h-1 opacity-0"
/>
) : null}
{/* Peer identity + status */}
<View className="items-center mt-16">
<View className="w-28 h-28 rounded-full bg-general-400 items-center justify-center mb-6">
<Text className="text-4xl font-JakartaBold text-white">
{(peerName.trim()[0] ?? "?").toUpperCase()}
</Text>
</View>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{peerName}
</Text>
<Text className="text-base mt-1 text-general-200 dark:text-neutral-400">
{call.status === "incoming"
? t("call.incoming")
: call.status === "outgoing" || call.status === "connecting"
? t("call.connectingWith", { name: peerName })
: call.status === "in-call"
? t("call.inCall")
: call.status === "ended"
? t("call.ended")
: t("call.connecting")}
</Text>
</View>
{/* Controls vary by state */}
<View className="flex-row items-center justify-center mb-10">
{call.status === "incoming" ? (
<>
<CallButton
icon="phone-hangup"
color="#ef4444"
label={t("call.decline")}
onPress={handleDecline}
/>
<CallButton
icon="phone"
color="#22c55e"
label={t("call.accept")}
onPress={handleAccept}
/>
</>
) : (
<>
<CallButton
icon={call.muted ? "microphone-off" : "microphone"}
color={call.muted ? "#ef4444" : "#6b7280"}
label={call.muted ? t("call.unmute") : t("call.mute")}
onPress={call.toggleMute}
/>
<CallButton
icon="phone-hangup"
color="#ef4444"
label={t("call.end")}
onPress={handleEnd}
/>
<CallButton
icon={call.speakerOn ? "volume-high" : "volume-off"}
color={call.speakerOn ? "#0286ff" : "#6b7280"}
label={call.speakerOn ? t("call.speaker") : t("call.speakerOff")}
onPress={call.toggleSpeaker}
/>
</>
)}
</View>
</SafeAreaView>
);
};
const CallButton = ({
icon,
color,
label,
onPress,
}: {
icon: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
color: string;
label: string;
onPress: () => void;
}) => (
<TouchableOpacity
onPress={onPress}
className="items-center mx-6"
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<View
className="w-16 h-16 rounded-full items-center justify-center"
style={{ backgroundColor: color }}
>
<MaterialCommunityIcons name={icon} size={28} color="white" />
</View>
<Text className="text-xs mt-2 text-general-200 dark:text-neutral-400">
{label}
</Text>
</TouchableOpacity>
);
export default Call;
-349
View File
@@ -1,349 +0,0 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native";
import { CustomButton } from "@/components/custom-button";
import { RideLayout } from "@/components/ride-layout";
import { SERVICES } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { calculateTripFare } from "@/lib/map";
import { formatLBP } from "@/lib/pricing";
import { requestRide } from "@/lib/request-ride";
import { useSession } from "@/lib/session";
import { formatTime, haversine } from "@/lib/utils";
import { useLocationStore, useServiceStore } from "@/store";
type PaymentMethod = "cash" | "card";
type NearbyDriver = {
id: number;
first_name: string;
latitude: number;
longitude: number;
};
// Confirm-ride is now the request screen: the rider no longer browses and
// picks a driver. They see a single fare estimate + nearest-driver ETA, pick a
// payment method, and tap Request — auto-match assigns the driver and they're
// routed to the live status screen.
const ConfirmRide = () => {
const params = useLocalSearchParams<{ service?: string }>();
const {
userAddress,
userLatitude,
userLongitude,
destinationAddress,
destinationLatitude,
destinationLongitude,
} = useLocationStore();
const { service: storeService, setService } = useServiceStore();
const { user } = useSession();
const t = useT();
const service = params.service ?? storeService;
const selected = SERVICES.find((s) => s.id === service) ?? SERVICES[0];
const [method, setMethod] = useState<PaymentMethod>("cash");
const [estimate, setEstimate] = useState<{
fare: string;
durationSeconds: number;
} | null>(null);
const [nearestEta, setNearestEta] = useState<number | null>(null);
const [driversOnline, setDriversOnline] = useState<number | null>(null);
const [estimating, setEstimating] = useState(true);
const [processing, setProcessing] = useState(false);
// Trip fare estimate — one Directions call for the trip leg, recomputed when
// the route or service changes. Independent of driver availability.
useEffect(() => {
if (
!userLatitude ||
!userLongitude ||
!destinationLatitude ||
!destinationLongitude
)
return;
let cancelled = false;
setEstimating(true);
const run = async () => {
const trip = await calculateTripFare({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service: selected.id,
});
if (cancelled) return;
setEstimate(
trip
? { fare: trip.fare, durationSeconds: trip.durationSeconds }
: null,
);
};
void run().finally(() => {
if (!cancelled) setEstimating(false);
});
return () => {
cancelled = true;
};
}, [
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
selected.id,
]);
// Online-driver availability for the selected service, polled so the "no
// drivers" state self-heals the moment a driver of this service comes
// online. The nearest driver's pickup ETA is resolved alongside the count.
useEffect(() => {
if (!userLatitude || !userLongitude) return;
let cancelled = false;
const check = async () => {
try {
const res = await fetchAPI(
`/(api)/driver/nearby?service=${selected.id}&lat=${userLatitude}&lng=${userLongitude}`,
);
const drivers = (res.data ?? []) as NearbyDriver[];
if (cancelled) return;
setDriversOnline(drivers.length);
if (drivers.length === 0) {
setNearestEta(null);
return;
}
const nearest = drivers
.map((d) => ({
d,
dist: haversine(
userLatitude,
userLongitude,
d.latitude,
d.longitude,
),
}))
.sort((a, b) => a.dist - b.dist)[0].d;
const directionsRes = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?origin=${nearest.latitude},${nearest.longitude}&destination=${userLatitude},${userLongitude}&key=${process.env.EXPO_PUBLIC_GOOGLE_API_KEY}`,
);
const data = await directionsRes.json();
const leg = data.routes?.[0]?.legs?.[0];
if (!cancelled)
setNearestEta(leg ? Math.round(leg.duration.value / 60) : null);
} catch {
if (!cancelled) {
setDriversOnline(null);
setNearestEta(null);
}
}
};
void check();
const timer = setInterval(() => void check(), 10000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [userLatitude, userLongitude, selected.id]);
const request = async () => {
if (!userLatitude || !userLongitude || !destinationLatitude || !destinationLongitude) {
Alert.alert(
t("confirmRide.alertMissingRouteTitle"),
t("confirmRide.alertMissingRouteBody"),
);
return;
}
if (!estimate) {
Alert.alert(
t("confirmRide.alertNoEstimateTitle"),
t("confirmRide.alertNoEstimateBody"),
);
return;
}
// Nested so the guards above narrow userLatitude/estimate to non-null for
// the card-confirm callback as well as the direct cash path.
const doRequest = async () => {
setProcessing(true);
try {
// Keep the store in sync with whatever service we resolved for this ride.
setService(selected.id);
const { ride } = await requestRide({
method,
service: selected.id,
user: { name: user?.name ?? "", email: user?.email ?? "" },
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("[REQUEST_RIDE]: ", err);
const msg =
err instanceof ApiError
? err.message
: t("confirmRide.alertErrorFallback");
Alert.alert(t("confirmRide.alertErrorTitle"), msg);
} finally {
setProcessing(false);
}
};
if (method === "card") {
Alert.alert(
t("confirmRide.alertPayCardTitle"),
t("confirmRide.alertPayCardBody", { fare: estimate.fare }),
[
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.continue"), onPress: () => void doRequest() },
],
);
} else {
void doRequest();
}
};
return (
<RideLayout title={t("confirmRide.title")} snapPoints={["60%", "88%"]}>
<Text className="text-xl font-JakartaSemiBold mb-1 text-black dark:text-white">
{t("confirmRide.yourTrip")}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.pickup")}
</Text>
</View>
<Text className="font-JakartaMedium mb-3 text-black dark:text-white" numberOfLines={1}>
{userAddress}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.destination")}
</Text>
</View>
<Text className="font-JakartaMedium mb-4 text-black dark:text-white" numberOfLines={1}>
{destinationAddress}
</Text>
<View className="flex-row items-center justify-between bg-general-500 dark:bg-neutral-950 rounded-2xl p-4 mb-4">
<View>
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t(selected.labelKey)} · {t(selected.taglineKey)}
</Text>
<Text className="text-general-200 dark:text-neutral-400 text-xs mt-1">
{t("confirmRide.tripTime", {
time: estimate ? formatTime(estimate.durationSeconds / 60) : "…",
})}
</Text>
</View>
<View className="items-end">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{estimating
? "…"
: estimate
? t("confirmRide.fareDisplay", { fare: estimate.fare })
: "—"}
</Text>
{estimate ? (
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("confirmRide.lbpEstimate", {
lbp: formatLBP(parseFloat(estimate.fare)),
})}
</Text>
) : null}
</View>
</View>
<Text
className={`text-base font-JakartaMedium mb-2 ${
driversOnline === 0
? "text-rose-500"
: "text-general-200 dark:text-neutral-400"
}`}
>
{driversOnline === 0
? t("confirmRide.noDrivers", { service: t(selected.labelKey) })
: nearestEta == null
? t("confirmRide.findingDrivers")
: t("confirmRide.nearestDriver", { eta: nearestEta })}
</Text>
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2 text-black dark:text-white">
{t("confirmRide.paymentMethod")}
</Text>
<View className="flex-row gap-x-3 mb-2">
<TouchableOpacity
onPress={() => setMethod("cash")}
className={`flex-1 items-center py-3 rounded-xl border ${
method === "cash"
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "cash" ? "text-white" : "text-black dark:text-white"
}`}
>
{t("confirmRide.cash")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setMethod("card")}
className={`flex-1 items-center py-3 rounded-xl border ${
method === "card"
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "card" ? "text-white" : "text-black dark:text-white"
}`}
>
{t("confirmRide.card")}
</Text>
</TouchableOpacity>
</View>
<CustomButton
title={
processing
? t("confirmRide.requesting")
: driversOnline === 0
? t("confirmRide.noDriversOnline")
: method === "cash"
? t("confirmRide.requestCash")
: t("confirmRide.requestCard")
}
className="mt-4"
onPress={request}
disabled={processing || estimating || !estimate || driversOnline === 0}
/>
</RideLayout>
);
};
export default ConfirmRide;
+11
View File
@@ -0,0 +1,11 @@
import { ChatThread } from "@/components/chat-thread";
// Standalone chat screen for the driver side. Reuses the same ChatThread as
// the rider's (tabs) Chat screen, but outside the rider's (tabs) navigator —
// routing a driver into "/(root)/(tabs)/chat" would mount the rider's tab bar
// (Home/Rides/Chat/Profile/Settings) around them, exposing rider-only screens
// and clashing visually with the composer at the bottom. No tab bar here, so
// no extra clearance is needed.
const DriverChat = () => <ChatThread />;
export default DriverChat;
+1527 -123
View File
File diff suppressed because it is too large Load Diff
+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;