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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+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