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
@@ -0,0 +1,146 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { RatingSheet } from "@/components/rating-sheet";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
// Home-screen banner for unfinished business. Two things can be unfinished
|
||||
// after the rider leaves the tracking screen:
|
||||
//
|
||||
// * a ride still in flight — before this, killing the app mid-ride stranded
|
||||
// the rider with no route back to their driver, since home only lists
|
||||
// completed history;
|
||||
// * a finished ride they never rated — the prompt is easy to miss when the
|
||||
// app is backgrounded the moment the door closes.
|
||||
//
|
||||
// Both are recoverable from one poll, so they share one banner.
|
||||
|
||||
const POLL_MS = 15000;
|
||||
|
||||
type ActiveRide = {
|
||||
ride_id: number;
|
||||
status: string;
|
||||
service: string;
|
||||
destination_address: string;
|
||||
driver_name: string | null;
|
||||
};
|
||||
|
||||
type PendingRating = {
|
||||
ride_id: number;
|
||||
destination_address: string;
|
||||
driver_name: string | null;
|
||||
driver_avatar: string | null;
|
||||
};
|
||||
|
||||
const STATUS_KEY: Record<string, string> = {
|
||||
requested: "bookRide.status.requested",
|
||||
accepted: "bookRide.status.accepted",
|
||||
arrived: "bookRide.status.arrived",
|
||||
en_route: "bookRide.status.enRoute",
|
||||
};
|
||||
|
||||
export const ActiveRideBanner = () => {
|
||||
const t = useT();
|
||||
const [active, setActive] = useState<ActiveRide | null>(null);
|
||||
const [pending, setPending] = useState<PendingRating | null>(null);
|
||||
const [ratingOpen, setRatingOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<number[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/ride/active");
|
||||
setActive(res.data?.active ?? null);
|
||||
setPending(res.data?.pending_rating ?? null);
|
||||
} catch (err) {
|
||||
// A signed-out or offline home screen simply shows no banner.
|
||||
console.log("[ACTIVE_RIDE_BANNER]: ", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(root)/book-ride",
|
||||
params: { id: String(active.ride_id) },
|
||||
})
|
||||
}
|
||||
className="bg-primary-500 rounded-2xl p-4 mb-4 flex-row items-center"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white/80 text-xs font-JakartaMedium">
|
||||
{STATUS_KEY[active.status]
|
||||
? t(STATUS_KEY[active.status])
|
||||
: active.status}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-white font-JakartaBold mt-0.5"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{active.driver_name
|
||||
? t("home.activeRideWithDriver", { name: active.driver_name })
|
||||
: active.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (pending && !dismissed.includes(pending.ride_id)) {
|
||||
return (
|
||||
<>
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-4 flex-row items-center">
|
||||
<View className="flex-1">
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
|
||||
{t("home.rateLastRide")}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-black dark:text-white font-JakartaBold mt-0.5"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{pending.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setRatingOpen(true)}
|
||||
className="bg-primary-500 rounded-full px-4 py-2 ml-3"
|
||||
>
|
||||
<Text className="text-white font-JakartaBold text-xs">
|
||||
{t("home.rate")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<RatingSheet
|
||||
visible={ratingOpen}
|
||||
rideId={pending.ride_id}
|
||||
audience="rider"
|
||||
subjectName={pending.driver_name}
|
||||
subjectAvatar={pending.driver_avatar}
|
||||
onDone={() => {
|
||||
setRatingOpen(false);
|
||||
setDismissed((prev) => [...prev, pending.ride_id]);
|
||||
void load();
|
||||
}}
|
||||
onSkip={() => {
|
||||
setRatingOpen(false);
|
||||
setDismissed((prev) => [...prev, pending.ride_id]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import type { CallRecord, ChatActiveRide } from "@/types/type";
|
||||
|
||||
// Listens for an incoming WebRTC call (a 'ringing' call row this user did not
|
||||
// place) and routes the user to the call screen — regardless of which tab is
|
||||
// open. Rendered once at the root layout level; emits no UI.
|
||||
//
|
||||
// It only polls while an active ride exists (the only window in which a call
|
||||
// can happen). To avoid re-navigating on every poll, it remembers the call id
|
||||
// it already handed off to the call screen and resets once that call goes
|
||||
// terminal.
|
||||
|
||||
const ACTIVE_POLL_MS = 5000;
|
||||
const CALL_POLL_MS = 3000;
|
||||
|
||||
const CallWatcher = () => {
|
||||
// The ride we're watching for an incoming call on.
|
||||
const rideIdRef = useRef<number | null>(null);
|
||||
// The call id we've already navigated to, so we don't re-push the screen.
|
||||
const handledCallIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// Refresh which ride (if any) is active for this user, then poll its call
|
||||
// row. Both run on intervals; the call poll no-ops until a rideId is known.
|
||||
const activeTimer = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/chat/active");
|
||||
const active = (res.data ?? null) as ChatActiveRide | null;
|
||||
if (cancelled) return;
|
||||
rideIdRef.current = active?.ride_id ?? null;
|
||||
} catch (err) {
|
||||
console.log("[CALL_WATCHER_ACTIVE]: ", err);
|
||||
}
|
||||
}, ACTIVE_POLL_MS);
|
||||
|
||||
const callTimer = setInterval(async () => {
|
||||
const rideId = rideIdRef.current;
|
||||
if (rideId === null) return;
|
||||
try {
|
||||
const res = await fetchAPI(`/(api)/ride/${rideId}/call`);
|
||||
const call = (res.data ?? null) as CallRecord | null;
|
||||
if (cancelled || !call) return;
|
||||
|
||||
// A terminal call clears the handled marker so the next incoming call
|
||||
// can navigate again.
|
||||
if (
|
||||
call.status === "ended" ||
|
||||
call.status === "declined" ||
|
||||
call.status === "missed"
|
||||
) {
|
||||
if (handledCallIdRef.current === call.id) {
|
||||
handledCallIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// An incoming ringing call we didn't place: hand off to the call
|
||||
// screen, once per call id.
|
||||
if (call.status === "ringing" && !call.is_caller) {
|
||||
if (handledCallIdRef.current === call.id) return;
|
||||
handledCallIdRef.current = call.id;
|
||||
router.push({
|
||||
pathname: "/(root)/call",
|
||||
params: { rideId: String(rideId), mode: "incoming" },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[CALL_WATCHER_CALL]: ", err);
|
||||
}
|
||||
}, CALL_POLL_MS);
|
||||
|
||||
// Kick the active poll immediately so an incoming call on a freshly
|
||||
// matched ride is noticed without waiting for the first interval.
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/chat/active");
|
||||
if (cancelled) return;
|
||||
rideIdRef.current =
|
||||
((res.data ?? null) as ChatActiveRide | null)?.ride_id ?? null;
|
||||
} catch {
|
||||
// ignore — the interval will retry
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(activeTimer);
|
||||
clearInterval(callTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CallWatcher;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
// Cancelling asks *why* before it asks "are you sure". The reason codes are
|
||||
// fixed (lib/ride-lifecycle CANCELLATION_REASONS) rather than free text, so
|
||||
// the admin portal can count them — "driver never showed" and "I changed my
|
||||
// mind" are the same cancellation in the ledger otherwise, and only one of
|
||||
// them is a problem worth chasing.
|
||||
|
||||
const RIDER_REASONS = [
|
||||
"wait_too_long",
|
||||
"driver_no_show",
|
||||
"unreachable",
|
||||
"wrong_address",
|
||||
"changed_mind",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
const DRIVER_REASONS = [
|
||||
"rider_no_show",
|
||||
"unreachable",
|
||||
"wrong_address",
|
||||
"vehicle_issue",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
audience: "rider" | "driver";
|
||||
submitting?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
};
|
||||
|
||||
export const CancelSheet = ({
|
||||
visible,
|
||||
audience,
|
||||
submitting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
const reasons = audience === "rider" ? RIDER_REASONS : DRIVER_REASONS;
|
||||
|
||||
return (
|
||||
<ReactNativeModal isVisible={visible} onBackdropPress={onCancel}>
|
||||
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
|
||||
<Text className="text-xl font-JakartaBold text-black dark:text-white">
|
||||
{t("cancelSheet.title")}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 mt-1 mb-4">
|
||||
{audience === "rider"
|
||||
? t("cancelSheet.subtitleRider")
|
||||
: t("cancelSheet.subtitleDriver")}
|
||||
</Text>
|
||||
|
||||
{reasons.map((code) => {
|
||||
const selected = reason === code;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={code}
|
||||
onPress={() => setReason(code)}
|
||||
className={`rounded-2xl border px-4 py-3 mb-2 ${
|
||||
selected
|
||||
? "border-primary-500 bg-primary-500/10"
|
||||
: "border-neutral-200 dark:border-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-JakartaMedium ${
|
||||
selected ? "text-primary-500" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{t(`cancelSheet.reasons.${code}`)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => reason && onConfirm(reason)}
|
||||
disabled={!reason || submitting}
|
||||
className={`rounded-full py-3 items-center mt-3 bg-rose-500 ${
|
||||
!reason || submitting ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Text className="font-JakartaBold text-white">
|
||||
{submitting
|
||||
? t("cancelSheet.cancelling")
|
||||
: t("cancelSheet.confirm")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onCancel} className="py-3 mt-1">
|
||||
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("cancelSheet.keepRide")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Image,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
|
||||
import { images } from "@/constants";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { ensureMicPermission } from "@/lib/use-call";
|
||||
import { useChat } from "@/lib/use-chat";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import type { ChatActiveRide, Message } from "@/types/type";
|
||||
|
||||
const initials = (name: string): string => {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
};
|
||||
|
||||
type ChatThreadProps = {
|
||||
/**
|
||||
* Extra clearance (px) the composer needs below the safe area — nonzero
|
||||
* when this screen sits under the rider's floating tab bar (position:
|
||||
* "absolute", ~78px tall + 20px margin), which doesn't reserve layout
|
||||
* space of its own and would otherwise sit on top of the composer. Pass 0
|
||||
* for a standalone screen (no tab bar underneath, e.g. the driver's).
|
||||
*/
|
||||
tabBarClearance?: number;
|
||||
};
|
||||
|
||||
// Ride-scoped chat thread: header with the peer + call button, message list,
|
||||
// and composer. Shared by the rider's (tabs) Chat screen and the driver's
|
||||
// standalone chat screen — both resolve the same conversation via
|
||||
// GET /(api)/chat/active, which returns the correct peer for either role.
|
||||
export const ChatThread = ({ tabBarClearance = 0 }: ChatThreadProps) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [active, setActive] = useState<ChatActiveRide | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
|
||||
// Resolve which conversation (if any) is open for the signed-in user. Re-run
|
||||
// whenever the screen is focused so a just-matched ride appears immediately.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setResolving(true);
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/chat/active");
|
||||
if (!cancelled) setActive((res.data ?? null) as ChatActiveRide);
|
||||
} catch (err) {
|
||||
console.log("[CHAT_ACTIVE]: ", err);
|
||||
if (!cancelled) setActive(null);
|
||||
} finally {
|
||||
if (!cancelled) setResolving(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
const rideId = active?.ride_id ?? null;
|
||||
const role = active?.role ?? null;
|
||||
const { messages, loading, sending, sendMessage } = useChat(rideId, role);
|
||||
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const peer = active?.peer ?? null;
|
||||
const peerName = peer?.name ?? "";
|
||||
|
||||
// Prime the mic permission as soon as a conversation (and its Call button)
|
||||
// is on screen, so the OS prompt lands here — not mid-handshake after the
|
||||
// user has already tapped Call and navigated to the call screen.
|
||||
const hasPeer = Boolean(peer);
|
||||
useEffect(() => {
|
||||
if (hasPeer) void ensureMicPermission();
|
||||
}, [hasPeer]);
|
||||
|
||||
const openCall = useCallback(() => {
|
||||
if (!active) return;
|
||||
router.push({
|
||||
pathname: "/(root)/call",
|
||||
params: {
|
||||
rideId: String(active.ride_id),
|
||||
role: active.role,
|
||||
mode: "start",
|
||||
},
|
||||
});
|
||||
}, [active]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
setDraft("");
|
||||
void sendMessage(text);
|
||||
Keyboard.dismiss();
|
||||
}, [draft, sending, sendMessage]);
|
||||
|
||||
const renderBubble = useCallback(
|
||||
({ item }: { item: Message }) => {
|
||||
const mine = item.sender_type === role;
|
||||
return (
|
||||
<View
|
||||
className={`flex-row ${mine ? "justify-end" : "justify-start"} my-1`}
|
||||
>
|
||||
<View
|
||||
className={`max-w-[78%] rounded-2xl px-4 py-2.5 ${
|
||||
mine ? "bg-general-400" : "bg-neutral-100 dark:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[15px] ${
|
||||
mine ? "text-white" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{item.body}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[role],
|
||||
);
|
||||
|
||||
const emptyConversation = useMemo(
|
||||
() => (
|
||||
<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>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (resolving) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
|
||||
<ActivityIndicator size="large" color={isDark ? "#fff" : "#0286ff"} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="flex-1 bg-white dark:bg-neutral-950"
|
||||
edges={["top"]}
|
||||
>
|
||||
{/* Conversation header — only when a ride is matched */}
|
||||
{active && peer ? (
|
||||
<View className="flex-row items-center px-4 py-3 border-b border-neutral-100 dark:border-neutral-800">
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(root)/book-ride",
|
||||
params: { id: String(active.ride_id) },
|
||||
})
|
||||
}
|
||||
className="flex-row items-center flex-1"
|
||||
>
|
||||
{peer.avatar ? (
|
||||
<Image
|
||||
source={{ uri: driverPhotoUri(peer.avatar) }}
|
||||
className="w-10 h-10 rounded-full bg-neutral-200 dark:bg-neutral-700"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-10 h-10 rounded-full bg-general-400 items-center justify-center">
|
||||
<Text className="text-white font-JakartaBold">
|
||||
{initials(peerName)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="ml-3">
|
||||
<Text className="text-base font-JakartaBold text-black dark:text-white">
|
||||
{peerName}
|
||||
</Text>
|
||||
{peer.car_model ? (
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{peer.car_model}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={openCall}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
accessibilityLabel={t("chat.call")}
|
||||
className="w-10 h-10 rounded-full bg-general-300 dark:bg-neutral-800 items-center justify-center"
|
||||
>
|
||||
<MaterialCommunityIcons name="phone" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{active && peer ? (
|
||||
<>
|
||||
{loading && messages.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={isDark ? "#fff" : "#0286ff"}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={(m) => String(m.id)}
|
||||
renderItem={renderBubble}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
onScrollBeginDrag={Keyboard.dismiss}
|
||||
keyboardShouldPersistTaps="never"
|
||||
ListEmptyComponent={emptyConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<View
|
||||
className="flex-row items-center px-3 py-2 border-t border-neutral-100 dark:border-neutral-800"
|
||||
style={{ paddingBottom: insets.bottom + 8 + tabBarClearance }}
|
||||
>
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder={t("chat.inputPlaceholder")}
|
||||
placeholderTextColor={isDark ? "#737373" : "#9ca3af"}
|
||||
className="flex-1 min-h-[44px] max-h-28 rounded-full bg-neutral-100 dark:bg-neutral-800 px-4 py-2.5 text-[15px] text-black dark:text-white"
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={sending || !draft.trim()}
|
||||
accessibilityLabel={t("chat.send")}
|
||||
className="w-11 h-11 ml-2 rounded-full bg-general-400 items-center justify-center disabled:opacity-40"
|
||||
>
|
||||
<MaterialCommunityIcons name="send" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className="flex-1 px-5">{emptyConversation}</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -40,9 +40,14 @@ export const CustomButton = ({
|
||||
iconLeft: IconLeft,
|
||||
iconRight: IconRight,
|
||||
className,
|
||||
// Which touchable the button is built on. React Native's own works
|
||||
// everywhere except inside a @gorhom/bottom-sheet on Android, where the
|
||||
// sheet's gesture handler eats the first press — the button only fires on
|
||||
// the second tap. Screens hosted in a sheet pass the sheet's touchable.
|
||||
Touchable = TouchableOpacity,
|
||||
...props
|
||||
}: ButtonProps) => (
|
||||
<TouchableOpacity
|
||||
<Touchable
|
||||
onPress={onPress}
|
||||
className={`w-full rounded-full p-3 flex flex-row justify-center items-center shadow-md shadow-neutral-400/70 dark:shadow-neutral-950/70 ${getBgVariantStyle(bgVariant)} ${className}`}
|
||||
{...props}
|
||||
@@ -54,5 +59,5 @@ export const CustomButton = ({
|
||||
</Text>
|
||||
|
||||
{IconRight && <IconRight />}
|
||||
</TouchableOpacity>
|
||||
</Touchable>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type * as ImagePicker from "expo-image-picker";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { alertPermissionDenied } from "@/lib/capture-permission";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { loadImagePicker } from "@/lib/image-picker";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
/** The three documents a Lebanese driver is vetted against. */
|
||||
export type DocumentType = "license" | "id" | "vehicle_reg";
|
||||
|
||||
/**
|
||||
* What a scan can fill in. Every field is optional and independent: a licence
|
||||
* whose number reads cleanly but whose expiry is smudged yields just the
|
||||
* number. Mirrors ExtractedFields on the server — deliberately redeclared here
|
||||
* so the client bundle doesn't pull in lib/document-ocr.ts, which is Node-only.
|
||||
*/
|
||||
export type ScannedFields = {
|
||||
license_number?: string;
|
||||
license_expiry?: string;
|
||||
national_id?: string;
|
||||
plate_number?: string;
|
||||
car_model?: string;
|
||||
};
|
||||
|
||||
type ScanResponse = {
|
||||
data: {
|
||||
doc_type: DocumentType;
|
||||
document: string;
|
||||
fields: ScannedFields;
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Photographs one document, sends it for OCR, and reports back both the stored
|
||||
* scan's name (which goes with the profile submission) and whatever fields
|
||||
* were read off it.
|
||||
*
|
||||
* The component never writes to the form itself — it hands the values up, and
|
||||
* the form decides what to do with them. That separation is what lets a driver
|
||||
* correct a misread field and not have the next scan silently stamp over it.
|
||||
* A failed read is not an error state here: the scan is still stored for the
|
||||
* reviewer, and the driver types the details in by hand as before.
|
||||
*/
|
||||
export const DocumentScanner = ({
|
||||
docType,
|
||||
label,
|
||||
hint,
|
||||
optional = false,
|
||||
onFile = false,
|
||||
onScanned,
|
||||
}: {
|
||||
docType: DocumentType;
|
||||
label: string;
|
||||
hint: string;
|
||||
optional?: boolean;
|
||||
/** A scan of this document is already stored — resubmitting may not need a new one. */
|
||||
onFile?: boolean;
|
||||
onScanned: (document: string, fields: ScannedFields) => void;
|
||||
}) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const upload = async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
if (!asset.base64) {
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.scan.errorBody"));
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus(null);
|
||||
setFailed(false);
|
||||
|
||||
try {
|
||||
const { data } = (await fetchAPI("/(api)/driver/scan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ doc_type: docType, image_base64: asset.base64 }),
|
||||
})) as ScanResponse;
|
||||
|
||||
setPreview(asset.uri);
|
||||
onScanned(data.document, data.fields);
|
||||
|
||||
const filled = Object.values(data.fields).filter(Boolean).length;
|
||||
|
||||
// Three outcomes worth telling apart: OCR read something, OCR ran and
|
||||
// found nothing usable, or OCR never ran. All three keep the scan; only
|
||||
// the wording changes, because in every case the driver's next move is
|
||||
// to check the fields below.
|
||||
setStatus(
|
||||
filled > 0
|
||||
? t("driver.scan.filled", undefined, filled)
|
||||
: data.code === "OCR_UNAVAILABLE"
|
||||
? t("driver.scan.savedUnreadable")
|
||||
: t("driver.scan.savedNoFields"),
|
||||
);
|
||||
} catch (err) {
|
||||
console.log("[DOCUMENT_SCAN]: ", err);
|
||||
|
||||
const code =
|
||||
err instanceof ApiError
|
||||
? (err.body?.code as string | undefined)
|
||||
: undefined;
|
||||
|
||||
Alert.alert(
|
||||
t("driver.scan.errorTitle"),
|
||||
code === "IMAGE_TOO_LARGE"
|
||||
? t("driver.scan.errorTooLarge")
|
||||
: code === "SCAN_RATE_LIMIT"
|
||||
? t("driver.scan.errorRateLimit")
|
||||
: code === "UNSUPPORTED_IMAGE"
|
||||
? t("driver.scan.errorUnsupported")
|
||||
: t("driver.scan.errorBody"),
|
||||
);
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const capture = async (source: "camera" | "library") => {
|
||||
if (busy) return;
|
||||
|
||||
// Loaded on demand: on a binary built before expo-image-picker was added
|
||||
// the native module is missing, and importing it at the top of this file
|
||||
// would take the whole app down instead of just this button.
|
||||
const picker = loadImagePicker();
|
||||
if (!picker) {
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask only for the permission the tapped button actually needs — a driver
|
||||
// who refuses the camera can still pick an existing photo of their papers.
|
||||
let permission: ImagePicker.PermissionResponse;
|
||||
|
||||
try {
|
||||
permission =
|
||||
source === "camera"
|
||||
? await picker.requestCameraPermissionsAsync()
|
||||
: await picker.requestMediaLibraryPermissionsAsync();
|
||||
} catch (error) {
|
||||
console.log("[DOCUMENT_SCAN_PERMISSION]: ", error);
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
alertPermissionDenied(permission, {
|
||||
title: t("driver.scan.permissionTitle"),
|
||||
message:
|
||||
source === "camera"
|
||||
? t("driver.scan.permissionCamera")
|
||||
: t("driver.scan.permissionLibrary"),
|
||||
blocked:
|
||||
source === "camera"
|
||||
? t("driver.scan.permissionCameraBlocked")
|
||||
: t("driver.scan.permissionLibraryBlocked"),
|
||||
openSettings: t("common.openSettings"),
|
||||
cancel: t("common.cancel"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// `quality: 0.6` keeps a phone photo comfortably under the upload cap
|
||||
// while staying sharp enough to read small print; no cropping step,
|
||||
// because OCR wants the whole card and an edited crop routinely loses the
|
||||
// line the expiry date sits on.
|
||||
const options: ImagePicker.ImagePickerOptions = {
|
||||
mediaTypes: picker.MediaTypeOptions.Images,
|
||||
quality: 0.6,
|
||||
base64: true,
|
||||
exif: false,
|
||||
};
|
||||
|
||||
let result: ImagePicker.ImagePickerResult;
|
||||
|
||||
try {
|
||||
result =
|
||||
source === "camera"
|
||||
? await picker.launchCameraAsync(options)
|
||||
: await picker.launchImageLibraryAsync(options);
|
||||
} catch (error) {
|
||||
console.log("[DOCUMENT_SCAN_CAPTURE]: ", error);
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
|
||||
await upload(result.assets[0]);
|
||||
};
|
||||
|
||||
const scanned = preview !== null;
|
||||
|
||||
return (
|
||||
<View className="bg-neutral-100 dark:bg-neutral-900 rounded-2xl p-4 mb-4">
|
||||
<View className="flex-row items-start justify-between mb-1">
|
||||
<Text className="text-sm font-JakartaBold text-black dark:text-white flex-1 pr-2">
|
||||
{label}
|
||||
</Text>
|
||||
{optional ? (
|
||||
<Text className="text-[11px] font-JakartaSemiBold text-general-200 dark:text-neutral-500 uppercase">
|
||||
{t("driver.scan.optional")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-3">
|
||||
{hint}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center">
|
||||
{scanned ? (
|
||||
<Image
|
||||
source={{ uri: preview }}
|
||||
className="w-16 h-16 rounded-xl mr-3"
|
||||
resizeMode="cover"
|
||||
alt={label}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View className="flex-1 flex-row gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture("camera")}
|
||||
disabled={busy}
|
||||
className="flex-1 flex-row items-center justify-center rounded-full bg-primary-500 py-3 px-2"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={16}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<Text className="text-white font-JakartaBold text-xs ml-1.5">
|
||||
{scanned ? t("driver.scan.retake") : t("driver.scan.take")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture("library")}
|
||||
disabled={busy}
|
||||
className="flex-1 flex-row items-center justify-center rounded-full border border-neutral-300 dark:border-neutral-700 py-3 px-2"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="image-outline"
|
||||
size={16}
|
||||
color={isDark ? "#e5e5e5" : "#333333"}
|
||||
/>
|
||||
<Text className="text-black dark:text-white font-JakartaBold text-xs ml-1.5">
|
||||
{t("driver.scan.choose")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{busy ? (
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mt-3">
|
||||
{t("driver.scan.reading")}
|
||||
</Text>
|
||||
) : status ? (
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="check-circle-outline"
|
||||
size={14}
|
||||
color="#10b981"
|
||||
/>
|
||||
<Text className="text-xs font-JakartaSemiBold text-emerald-600 dark:text-emerald-400 ml-1.5 flex-1">
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
) : failed ? (
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="alert-outline"
|
||||
size={14}
|
||||
color="#f43f5e"
|
||||
/>
|
||||
<Text className="text-xs font-JakartaSemiBold text-rose-500 ml-1.5 flex-1">
|
||||
{t("driver.scan.errorRetry")}
|
||||
</Text>
|
||||
</View>
|
||||
) : onFile ? (
|
||||
// Resubmitting after a rejection: the reviewer already has a scan, so
|
||||
// say so rather than making the driver wonder whether it was lost.
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="paperclip"
|
||||
size={14}
|
||||
color={isDark ? "#9ca3af" : "#858585"}
|
||||
/>
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 ml-1.5 flex-1">
|
||||
{t("driver.scan.alreadyOnFile")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Image, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { tr } from "@/lib/i18n";
|
||||
import { formatTime } from "@/lib/utils";
|
||||
import { DriverCardProps } from "@/types/type";
|
||||
@@ -20,7 +21,7 @@ export const DriverCard = ({
|
||||
} flex flex-row items-center justify-between py-5 px-3 rounded-xl`}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: item.profile_image_url }}
|
||||
source={{ uri: driverPhotoUri(item.profile_image_url) }}
|
||||
alt={tr("components.driverCard.avatarAlt")}
|
||||
className="w-14 h-14 rounded-full"
|
||||
/>
|
||||
@@ -32,14 +33,24 @@ export const DriverCard = ({
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-row items-center space-x-1 ml-2">
|
||||
<Image source={icons.star} alt={tr("components.driverCard.starAlt")} className="w-3.5 h-3.5" />
|
||||
<Text className="text-sm font-JakartaRegular text-black dark:text-white">{item.rating}</Text>
|
||||
<Image
|
||||
source={icons.star}
|
||||
alt={tr("components.driverCard.starAlt")}
|
||||
className="w-3.5 h-3.5"
|
||||
/>
|
||||
<Text className="text-sm font-JakartaRegular text-black dark:text-white">
|
||||
{item.rating}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-start">
|
||||
<View className="flex flex-row items-center">
|
||||
<Image source={icons.dollar} alt={tr("components.driverCard.dollarAlt")} className="w-4 h-4" />
|
||||
<Image
|
||||
source={icons.dollar}
|
||||
alt={tr("components.driverCard.dollarAlt")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Text className="text-sm font-JakartaRegular ml-1 text-black dark:text-white">
|
||||
${item.price}
|
||||
</Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
Keyboard,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
@@ -104,6 +104,10 @@ export const GoogleTextInput = ({
|
||||
const onSelect = async (suggestion: Suggestion) => {
|
||||
setQuery(suggestion.text);
|
||||
setSuggestions([]);
|
||||
// The search is over the moment a place is picked. Left open, the keyboard
|
||||
// covers whatever the next tap was meant to be — and inside a bottom sheet
|
||||
// it holds the sheet in its extended state on top of it.
|
||||
Keyboard.dismiss();
|
||||
|
||||
try {
|
||||
const details = await fetchPlaceDetails(suggestion.placeId);
|
||||
@@ -147,6 +151,11 @@ export const GoogleTextInput = ({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Rendered as plain rows, not a FlatList. Places never returns more
|
||||
than a handful of predictions, so there is nothing to virtualise —
|
||||
and a list that scrolls inside the home feed (or inside the ride
|
||||
sheet) fights its parent for the gesture and swallows taps meant
|
||||
for a suggestion. */}
|
||||
{suggestions.length > 0 && (
|
||||
<View
|
||||
className="rounded-xl mt-1"
|
||||
@@ -155,21 +164,17 @@ export const GoogleTextInput = ({
|
||||
shadowColor: inputShadow,
|
||||
}}
|
||||
>
|
||||
<FlatList
|
||||
data={suggestions}
|
||||
keyExtractor={(item) => item.placeId}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => onSelect(item)}
|
||||
className="p-3 border-b border-general-700 dark:border-neutral-700"
|
||||
>
|
||||
<Text className="text-base font-JakartaRegular text-black dark:text-white">
|
||||
{item.text}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
{suggestions.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.placeId}
|
||||
onPress={() => onSelect(item)}
|
||||
className="p-3 border-b border-general-700 dark:border-neutral-700"
|
||||
>
|
||||
<Text className="text-base font-JakartaRegular text-black dark:text-white">
|
||||
{item.text}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
+310
-27
@@ -1,10 +1,16 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform, StyleSheet } from "react-native";
|
||||
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import MapView, {
|
||||
AnimatedRegion,
|
||||
Marker,
|
||||
MarkerAnimated,
|
||||
PROVIDER_DEFAULT,
|
||||
} from "react-native-maps";
|
||||
import MapViewDirections from "react-native-maps-directions";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { useFetch } from "@/lib/fetch";
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { tr } from "@/lib/i18n";
|
||||
import {
|
||||
calculateDriverTimes,
|
||||
@@ -12,15 +18,186 @@ import {
|
||||
generateMarkersFromData,
|
||||
} from "@/lib/map";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import { useNearbyDrivers } from "@/lib/use-nearby-drivers";
|
||||
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
|
||||
import type { Driver, MarkerData } from "@/types/type";
|
||||
import type { MarkerData } from "@/types/type";
|
||||
|
||||
// react-native-maps sizes itself from a real style object, so give it explicit
|
||||
// dimensions rather than relying on percentage classNames resolving to 0.
|
||||
const styles = StyleSheet.create({
|
||||
map: { ...StyleSheet.absoluteFillObject, borderRadius: 16 },
|
||||
markerBubble: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#111827",
|
||||
borderWidth: 2,
|
||||
borderColor: "#ffffff",
|
||||
// A flat dot on a light map is hard to pick out; a soft shadow lifts it.
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 3,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 4,
|
||||
},
|
||||
markerBubbleSelected: {
|
||||
backgroundColor: "#0286ff",
|
||||
},
|
||||
// Wraps bubble + arrow so the arrow can orbit the bubble by rotating the
|
||||
// whole frame, while the vehicle glyph inside stays upright and readable.
|
||||
markerFrame: {
|
||||
width: 54,
|
||||
height: 54,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
headingArrow: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// How long a marker takes to slide to its new position.
|
||||
//
|
||||
// Deliberately the poll interval, not less: each update is the car's position
|
||||
// as of that moment, so spreading the movement across the whole gap until the
|
||||
// next one is what makes a series of samples read as continuous travel. A
|
||||
// shorter duration would animate quickly and then sit frozen, which looks
|
||||
// worse than not animating at all.
|
||||
const MARKER_GLIDE_MS = 5000;
|
||||
|
||||
// Below this the GPS heading is mostly noise — a stationary phone reports
|
||||
// wildly varying directions — so the arrow is hidden and the car is simply
|
||||
// drawn as parked.
|
||||
const MOVING_KPH = 5;
|
||||
|
||||
// A driver pin drawn as the vehicle they actually drive.
|
||||
//
|
||||
// Every driver used to get the same car marker, so a moto rider watching a
|
||||
// motorbike approach saw a car on their map — and the four services were
|
||||
// indistinguishable at a glance. The glyphs come from the same SERVICES table
|
||||
// the service picker uses, so a pin and its tile always agree.
|
||||
const glyphFor = (service?: string | null) =>
|
||||
(SERVICES.find((s) => s.id === service) ?? SERVICES[0]).icon;
|
||||
|
||||
const ServiceMarker = ({
|
||||
marker,
|
||||
selected,
|
||||
}: {
|
||||
marker: MarkerData;
|
||||
selected: boolean;
|
||||
}) => {
|
||||
// Android renders a custom marker view by snapshotting it, and a snapshot
|
||||
// taken before layout is blank. Track changes briefly so the first real
|
||||
// frame is captured, then stop — leaving it on re-snapshots every marker on
|
||||
// every frame, which makes a map full of drivers crawl.
|
||||
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
||||
|
||||
const heading = marker.heading ?? null;
|
||||
const moving = (marker.speed_kph ?? 0) >= MOVING_KPH;
|
||||
const showArrow = moving && heading !== null;
|
||||
|
||||
// The marker's own coordinate, animated rather than assigned.
|
||||
//
|
||||
// Positions arrive every few seconds; setting them directly teleports each
|
||||
// car across the gap it covered since the last update. Holding the
|
||||
// coordinate in an AnimatedRegion and easing to each new fix turns the same
|
||||
// samples into visible travel — which is the whole point of showing other
|
||||
// drivers at all.
|
||||
const coordinate = useRef(
|
||||
new AnimatedRegion({
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
latitudeDelta: 0,
|
||||
longitudeDelta: 0,
|
||||
}),
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
// `timing` is not on the public typings for AnimatedRegion in this
|
||||
// version, though it exists at runtime; the cast keeps the call honest
|
||||
// without loosening the rest of the component.
|
||||
(
|
||||
coordinate as unknown as {
|
||||
timing: (config: Record<string, unknown>) => {
|
||||
start: () => void;
|
||||
};
|
||||
}
|
||||
)
|
||||
.timing({
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
latitudeDelta: 0,
|
||||
longitudeDelta: 0,
|
||||
duration: MARKER_GLIDE_MS,
|
||||
// AnimatedRegion drives a native prop that the native driver can't
|
||||
// handle, so this animation runs on the JS thread by necessity.
|
||||
useNativeDriver: false,
|
||||
})
|
||||
.start();
|
||||
}, [coordinate, marker.latitude, marker.longitude]);
|
||||
|
||||
useEffect(() => {
|
||||
setTracksViewChanges(true);
|
||||
const timer = setTimeout(() => setTracksViewChanges(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [selected, marker.service, showArrow, heading]);
|
||||
|
||||
// react-native-maps accepts an AnimatedRegion here at runtime — it is what
|
||||
// every animated-marker example passes — but types the prop as an animated
|
||||
// LatLng, so the two don't line up. Cast at the boundary rather than
|
||||
// loosening the component's own types.
|
||||
const animatedCoordinate = coordinate as unknown as React.ComponentProps<
|
||||
typeof MarkerAnimated
|
||||
>["coordinate"];
|
||||
|
||||
return (
|
||||
<MarkerAnimated
|
||||
coordinate={animatedCoordinate}
|
||||
title={marker.title}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
tracksViewChanges={tracksViewChanges}
|
||||
>
|
||||
<View style={styles.markerFrame}>
|
||||
{/* Rotating the frame swings the arrow around the bubble to point the
|
||||
way the car is travelling, while the bubble itself — and the
|
||||
vehicle glyph in it — stays upright and legible. */}
|
||||
{showArrow ? (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{ transform: [{ rotate: `${heading}deg` }] },
|
||||
styles.markerFrame,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="navigation"
|
||||
size={14}
|
||||
color={selected ? "#0286ff" : "#111827"}
|
||||
style={styles.headingArrow}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.markerBubble,
|
||||
selected ? styles.markerBubbleSelected : null,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={glyphFor(marker.service)}
|
||||
size={18}
|
||||
color="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</MarkerAnimated>
|
||||
);
|
||||
};
|
||||
|
||||
// "mutedStandard" is an Apple Maps type. Android's MapManager looks the value
|
||||
// up in a fixed table and unboxes the result into an int, so an unrecognised
|
||||
// name is a null Integer -> NullPointerException, and the map never draws.
|
||||
@@ -41,7 +218,52 @@ const MUTED_POI_STYLE = [
|
||||
},
|
||||
];
|
||||
|
||||
export const Map = () => {
|
||||
// The single driver assigned to a ride, as returned by GET /ride/:id. Used to
|
||||
// show the rider a live marker for the driver who accepted, instead of the
|
||||
// generic "nearby drivers of this service" search list.
|
||||
type TrackedDriver = {
|
||||
id: number;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
profile_image_url?: string | null;
|
||||
car_image_url?: string | null;
|
||||
car_seats?: number | null;
|
||||
rating?: number | null;
|
||||
car_model?: string | null;
|
||||
// Drives the pin glyph, so the rider watching their assigned driver arrive
|
||||
// sees a motorbike when a motorbike is coming.
|
||||
service?: string | null;
|
||||
};
|
||||
|
||||
type LatLng = { latitude: number; longitude: number };
|
||||
|
||||
export type MapProps = {
|
||||
trackedDriver?: TrackedDriver | null;
|
||||
/**
|
||||
* Show position and nearby drivers only — never a route line, and never zoom
|
||||
* out to fit a destination.
|
||||
*
|
||||
* The home map answers "where am I and what's around me". Drawing the
|
||||
* destination there meant a rider who had merely searched an address, or
|
||||
* finished a trip earlier, kept seeing a route to it every time they opened
|
||||
* the app.
|
||||
*/
|
||||
routeless?: boolean;
|
||||
// Driver view: override the store-derived origin/destination so the map
|
||||
// centers on the driver's own live position and pins the rider's pickup,
|
||||
// without touching the rider-facing location store.
|
||||
originOverride?: LatLng | null;
|
||||
destinationOverride?: (LatLng & { label?: string }) | null;
|
||||
};
|
||||
|
||||
export const Map = ({
|
||||
trackedDriver,
|
||||
originOverride,
|
||||
destinationOverride,
|
||||
routeless = false,
|
||||
}: MapProps = {}) => {
|
||||
const {
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
@@ -52,24 +274,47 @@ export const Map = () => {
|
||||
const { selectedDriver, setDrivers } = useDriverStore();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const trackingMode =
|
||||
Boolean(trackedDriver) ||
|
||||
originOverride !== undefined ||
|
||||
destinationOverride !== undefined;
|
||||
|
||||
// Online drivers of the selected service near the rider. Falls back to a
|
||||
// Beirut center when the rider's position isn't resolved yet so the map
|
||||
// still populates instead of sitting empty.
|
||||
//
|
||||
// The search starts tight around the rider and widens in 5 km steps only
|
||||
// when it finds nobody, so a rider on a busy street sees the cars actually
|
||||
// near them rather than every car in the country.
|
||||
const lat = userLatitude ?? 33.8938;
|
||||
const lng = userLongitude ?? 35.5018;
|
||||
const { data: drivers, error } = useFetch<Driver[]>(
|
||||
`/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`,
|
||||
);
|
||||
const { drivers } = useNearbyDrivers(service, lat, lng);
|
||||
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
const region = calculateRegion({
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
});
|
||||
// Region: in tracking mode, center on the driver's own position (or the
|
||||
// pickup point if that isn't resolved yet) instead of the rider's location
|
||||
// store, which tracking mode never touches.
|
||||
const region = trackingMode
|
||||
? calculateRegion({
|
||||
userLatitude:
|
||||
originOverride?.latitude ?? destinationOverride?.latitude ?? null,
|
||||
userLongitude:
|
||||
originOverride?.longitude ?? destinationOverride?.longitude ?? null,
|
||||
destinationLatitude: originOverride
|
||||
? (destinationOverride?.latitude ?? null)
|
||||
: null,
|
||||
destinationLongitude: originOverride
|
||||
? (destinationOverride?.longitude ?? null)
|
||||
: null,
|
||||
})
|
||||
: calculateRegion({
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude: routeless ? null : destinationLatitude,
|
||||
destinationLongitude: routeless ? null : destinationLongitude,
|
||||
});
|
||||
|
||||
// `initialRegion` is read once, at mount. The map mounts before the location
|
||||
// fix arrives, so it would sit on the Beirut fallback forever and never zoom
|
||||
@@ -89,6 +334,36 @@ export const Map = () => {
|
||||
}, [regionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedDriver) {
|
||||
setMarkers(
|
||||
trackedDriver.latitude != null && trackedDriver.longitude != null
|
||||
? [
|
||||
{
|
||||
id: trackedDriver.id,
|
||||
latitude: trackedDriver.latitude,
|
||||
longitude: trackedDriver.longitude,
|
||||
title:
|
||||
`${trackedDriver.first_name ?? ""} ${trackedDriver.last_name ?? ""}`.trim(),
|
||||
profile_image_url: trackedDriver.profile_image_url ?? "",
|
||||
car_image_url: trackedDriver.car_image_url ?? "",
|
||||
car_seats: trackedDriver.car_seats ?? 0,
|
||||
rating: trackedDriver.rating ?? 0,
|
||||
first_name: trackedDriver.first_name ?? "",
|
||||
last_name: trackedDriver.last_name ?? "",
|
||||
car_model: trackedDriver.car_model ?? null,
|
||||
service: trackedDriver.service ?? undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackingMode) {
|
||||
setMarkers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(drivers)) {
|
||||
if (!userLatitude || !userLongitude) return;
|
||||
|
||||
@@ -101,9 +376,10 @@ export const Map = () => {
|
||||
setMarkers(newMarkers);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drivers, userLatitude, userLongitude]);
|
||||
}, [trackedDriver, trackingMode, drivers, userLatitude, userLongitude]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackingMode) return;
|
||||
if (markers.length > 0 && destinationLatitude && destinationLongitude) {
|
||||
calculateDriverTimes({
|
||||
markers,
|
||||
@@ -117,6 +393,7 @@ export const Map = () => {
|
||||
});
|
||||
}
|
||||
}, [
|
||||
trackingMode,
|
||||
markers,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
@@ -130,7 +407,6 @@ export const Map = () => {
|
||||
// are an overlay, and calculateRegion falls back to Beirut without coords.
|
||||
// Previously either one failing replaced the whole map with a spinner or an
|
||||
// error line, which read as "the map didn't load".
|
||||
if (error) console.log("[MAP_DRIVERS]: ", error);
|
||||
|
||||
return (
|
||||
<MapView
|
||||
@@ -146,20 +422,26 @@ export const Map = () => {
|
||||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||||
>
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
<ServiceMarker
|
||||
key={marker.id}
|
||||
coordinate={{
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
}}
|
||||
title={marker.title}
|
||||
image={
|
||||
selectedDriver === marker.id ? icons.selectedMarker : icons.marker
|
||||
}
|
||||
marker={marker}
|
||||
selected={Boolean(trackedDriver) || selectedDriver === marker.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{userLatitude &&
|
||||
{destinationOverride ? (
|
||||
<Marker
|
||||
key="pickup"
|
||||
coordinate={{
|
||||
latitude: destinationOverride.latitude,
|
||||
longitude: destinationOverride.longitude,
|
||||
}}
|
||||
title={destinationOverride.label ?? tr("components.map.destination")}
|
||||
image={icons.pin}
|
||||
/>
|
||||
) : (
|
||||
!routeless &&
|
||||
userLatitude &&
|
||||
userLongitude &&
|
||||
destinationLatitude &&
|
||||
destinationLongitude && (
|
||||
@@ -188,7 +470,8 @@ export const Map = () => {
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</MapView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { useT } from "@/lib/i18n";
|
||||
import type { MapProps } from "@/components/map";
|
||||
|
||||
// react-native-maps does not support web. This stub keeps the web bundle
|
||||
// working for local testing; use a native build for real map functionality.
|
||||
export const Map = () => {
|
||||
export const Map = (_props: MapProps = {}) => {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import type { RideOffer } from "@/types/type";
|
||||
|
||||
// The drivers who have volunteered for a request, and the rider's choice
|
||||
// between them.
|
||||
//
|
||||
// Dispatch broadcasts the job and this is what comes back: several drivers,
|
||||
// none of them assigned, each waiting to be picked. So every row has to carry
|
||||
// what a person actually decides on — how far away they are, how they're
|
||||
// rated, what they drive — and picking one has to be a single deliberate tap,
|
||||
// because that tap is what commits the rider and releases everyone else.
|
||||
|
||||
// Rough road-speed assumption for turning a straight-line distance into
|
||||
// minutes. A per-offer Directions call would be more accurate and would also
|
||||
// mean one billed request per driver per poll; this is honest to within a
|
||||
// couple of minutes in city traffic, which is the precision a rider comparing
|
||||
// three drivers is actually using.
|
||||
const URBAN_KMH = 22;
|
||||
// Streets aren't straight. Multiplying the great-circle distance gets closer
|
||||
// to the distance a car really drives.
|
||||
const ROAD_FACTOR = 1.3;
|
||||
|
||||
const etaMinutes = (meters: number | null): number | null => {
|
||||
if (meters === null || !Number.isFinite(meters)) return null;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(((meters * ROAD_FACTOR) / 1000 / URBAN_KMH) * 60),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceLabel = (meters: number | null): string | null => {
|
||||
if (meters === null || !Number.isFinite(meters)) return null;
|
||||
return meters < 1000
|
||||
? `${Math.round(meters / 50) * 50} m`
|
||||
: `${(meters / 1000).toFixed(1)} km`;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
offers: RideOffer[];
|
||||
/** Offer currently being taken, so only that row shows a spinner. */
|
||||
pendingOfferId: number | null;
|
||||
busy: boolean;
|
||||
onPick: (offer: RideOffer) => void;
|
||||
};
|
||||
|
||||
export const OfferList = ({ offers, pendingOfferId, busy, onPick }: Props) => {
|
||||
const t = useT();
|
||||
|
||||
// What the rider is getting into. A driver who never filled in their car
|
||||
// model would otherwise leave the vehicle line blank on the one screen where
|
||||
// the rider is choosing between cars, so the service they drive for stands
|
||||
// in — "Car · 4 seats" is thin, but it isn't nothing.
|
||||
const vehicle = (offer: RideOffer): string => {
|
||||
const service = SERVICES.find((s) => s.id === offer.service);
|
||||
const label = offer.car_model ?? (service ? t(service.labelKey) : null);
|
||||
const seats = offer.car_seats
|
||||
? t("bookRide.offers.seats", undefined, offer.car_seats)
|
||||
: null;
|
||||
|
||||
return [label, seats].filter(Boolean).join(" · ");
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<Text className="text-base font-JakartaBold text-black dark:text-white">
|
||||
{t("bookRide.offers.title")}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{t("bookRide.offers.count", undefined, offers.length)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{offers.map((offer) => {
|
||||
const name = [offer.first_name, offer.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const distance = offer.pickup_distance_m ?? null;
|
||||
const eta = etaMinutes(distance);
|
||||
const taking = pendingOfferId === offer.offer_id;
|
||||
// The face the rider is choosing between. This is the screen the
|
||||
// driver's photo exists for, so it leads the row.
|
||||
const photo = driverPhotoUri(offer.profile_image_url);
|
||||
|
||||
return (
|
||||
<View
|
||||
key={offer.offer_id}
|
||||
className="bg-white dark:bg-neutral-900 rounded-2xl p-3 mb-2 flex-row items-center"
|
||||
>
|
||||
{photo ? (
|
||||
<Image
|
||||
source={{ uri: photo }}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-12 h-12 rounded-full bg-neutral-200 dark:bg-neutral-800 items-center justify-center">
|
||||
<MaterialCommunityIcons
|
||||
name="account"
|
||||
size={22}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="ml-3 flex-1">
|
||||
<Text
|
||||
className="font-JakartaSemiBold text-black dark:text-white"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{name || t("bookRide.match.driverFallback")}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mt-0.5">
|
||||
<View className="flex-row items-center gap-x-1">
|
||||
<MaterialCommunityIcons
|
||||
name="star"
|
||||
size={13}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{offer.rating != null
|
||||
? Number(offer.rating).toFixed(1)
|
||||
: t("bookRide.ratingFallback")}
|
||||
</Text>
|
||||
</View>
|
||||
{vehicle(offer) ? (
|
||||
<Text
|
||||
className="text-xs text-general-200 dark:text-neutral-400 flex-1"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{vehicle(offer)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{eta !== null ? (
|
||||
<Text className="text-xs font-JakartaMedium text-primary-500 mt-0.5">
|
||||
{t("bookRide.offers.away", {
|
||||
eta,
|
||||
distance: distanceLabel(distance) ?? "",
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onPick(offer)}
|
||||
disabled={busy}
|
||||
className={`rounded-full px-5 py-2.5 ml-2 ${
|
||||
busy && !taking ? "bg-emerald-500/40" : "bg-emerald-500"
|
||||
}`}
|
||||
>
|
||||
{taking ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text className="text-white font-JakartaBold text-xs">
|
||||
{t("bookRide.offers.pick")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
// How the rider pays, asked at the moment it becomes a real question: after
|
||||
// they have chosen a driver, not before they know one exists.
|
||||
//
|
||||
// The card path opens the gateway's hosted page and can take the better part
|
||||
// of a minute, during which the driver they picked could be taken by someone
|
||||
// else — so the sheet says what happens either way rather than dropping the
|
||||
// rider into a browser with no warning.
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
driverName: string | null;
|
||||
fareCents: number;
|
||||
submitting: boolean;
|
||||
onPay: (method: "cash" | "card") => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const PaymentChoiceSheet = ({
|
||||
visible,
|
||||
driverName,
|
||||
fareCents,
|
||||
submitting,
|
||||
onPay,
|
||||
onCancel,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const fare = (fareCents / 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<ReactNativeModal
|
||||
isVisible={visible}
|
||||
onBackdropPress={submitting ? undefined : onCancel}
|
||||
>
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5">
|
||||
<Text className="text-lg font-JakartaBold text-black dark:text-white">
|
||||
{driverName
|
||||
? t("bookRide.payment.titleNamed", { name: driverName })
|
||||
: t("bookRide.payment.title")}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 mt-1">
|
||||
{t("bookRide.payment.subtitle", { fare })}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onPay("cash")}
|
||||
disabled={submitting}
|
||||
className="flex-row items-center gap-x-3 rounded-2xl border border-neutral-200 dark:border-neutral-700 px-4 py-4 mt-4"
|
||||
>
|
||||
<MaterialCommunityIcons name="cash" size={22} color="#10b981" />
|
||||
<View className="flex-1">
|
||||
<Text className="font-JakartaBold text-black dark:text-white">
|
||||
{t("bookRide.payment.cash")}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{t("bookRide.payment.cashHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onPay("card")}
|
||||
disabled={submitting}
|
||||
className="flex-row items-center gap-x-3 rounded-2xl border border-neutral-200 dark:border-neutral-700 px-4 py-4 mt-2"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="credit-card-outline"
|
||||
size={22}
|
||||
color="#0286ff"
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text className="font-JakartaBold text-black dark:text-white">
|
||||
{t("bookRide.payment.card")}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{t("bookRide.payment.cardHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onCancel}
|
||||
disabled={submitting}
|
||||
className="items-center py-3 mt-2"
|
||||
>
|
||||
<Text className="font-JakartaBold text-general-200 dark:text-neutral-400">
|
||||
{submitting ? t("bookRide.payment.working") : t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
// The driver's half of the pickup handshake: they ask the rider for the code
|
||||
// on the rider's screen and type it here to start the trip. The code is never
|
||||
// sent to the driver's device, so a wrong entry is a real mismatch — either
|
||||
// the wrong passenger got in, or the driver is at the wrong car.
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
submitting?: boolean;
|
||||
/** Set when the server rejected the last attempt. */
|
||||
error?: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (code: string) => void;
|
||||
};
|
||||
|
||||
export const PickupCodeSheet = ({
|
||||
visible,
|
||||
submitting,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
return (
|
||||
<ReactNativeModal
|
||||
isVisible={visible}
|
||||
onBackdropPress={onCancel}
|
||||
avoidKeyboard
|
||||
>
|
||||
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
|
||||
<Text className="text-xl font-JakartaBold text-center text-black dark:text-white">
|
||||
{t("pickupCode.title")}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 text-center mt-1">
|
||||
{t("pickupCode.subtitle")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
value={code}
|
||||
onChangeText={(v) => setCode(v.replace(/\D/g, "").slice(0, 4))}
|
||||
keyboardType="number-pad"
|
||||
maxLength={4}
|
||||
autoFocus
|
||||
placeholder="0000"
|
||||
placeholderTextColor={isDark ? "#525252" : "#d4d4d4"}
|
||||
className="bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white rounded-2xl py-4 my-5 text-center text-3xl font-JakartaExtraBold tracking-[10px]"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-rose-500 text-sm text-center mb-3">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<CustomButton
|
||||
title={submitting ? "…" : t("pickupCode.startTrip")}
|
||||
bgVariant="success"
|
||||
onPress={() => onSubmit(code)}
|
||||
disabled={code.length < 4 || submitting}
|
||||
className={code.length < 4 ? "opacity-50" : ""}
|
||||
/>
|
||||
|
||||
<TouchableOpacity onPress={onCancel} className="py-3 mt-1">
|
||||
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useRef } from "react";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
import MapView, { PROVIDER_DEFAULT, type Region } from "react-native-maps";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
// Fine-tuning a pickup or drop-off point.
|
||||
//
|
||||
// The pin does NOT move — the map moves under it. Dragging a marker means
|
||||
// fighting for a few pixels with the same thumb that pans the map, and on a
|
||||
// phone the marker spends most of the gesture hidden under the finger holding
|
||||
// it. Anchoring the pin to the centre of the screen and sliding the map
|
||||
// underneath makes the target the one thing always visible, which is why every
|
||||
// ride-hailing app converged on it.
|
||||
//
|
||||
// The component is deliberately dumb: it reports the centre when the map
|
||||
// settles and nothing else. Reverse geocoding, debouncing and confirmation all
|
||||
// live on the screen, so this stays reusable for the origin and the
|
||||
// destination alike.
|
||||
|
||||
export type PinAdjusterProps = {
|
||||
initial: { latitude: number; longitude: number };
|
||||
/** Fired when the map stops moving, with the coordinate under the pin. */
|
||||
onSettled: (coords: { latitude: number; longitude: number }) => void;
|
||||
/** Fired as soon as a drag starts, to clear a now-stale address label. */
|
||||
onMoveStart?: () => void;
|
||||
};
|
||||
|
||||
// Tight enough that the rider is choosing a doorway, not a district.
|
||||
const ZOOM_DELTA = 0.004;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
map: StyleSheet.absoluteFillObject,
|
||||
// Sits above the map and ignores touches, so panning still reaches the map.
|
||||
pinLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pin: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
// The pin's point is at its bottom edge, but the coordinate we report is
|
||||
// the centre of the screen — so lift it by its own height to put the tip,
|
||||
// not the middle of the graphic, on the spot being chosen.
|
||||
marginBottom: 36,
|
||||
},
|
||||
// A small ground marker under the tip: without it, on a busy map, it is
|
||||
// genuinely hard to tell which pixel the pin is pointing at.
|
||||
dot: {
|
||||
position: "absolute",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(2,134,255,0.9)",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
export const PinAdjuster = ({
|
||||
initial,
|
||||
onSettled,
|
||||
onMoveStart,
|
||||
}: PinAdjusterProps) => {
|
||||
const { isDark } = useTheme();
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
const region: Region = {
|
||||
latitude: initial.latitude,
|
||||
longitude: initial.longitude,
|
||||
latitudeDelta: ZOOM_DELTA,
|
||||
longitudeDelta: ZOOM_DELTA,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
style={styles.map}
|
||||
initialRegion={region}
|
||||
showsUserLocation
|
||||
showsMyLocationButton={false}
|
||||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||||
onPanDrag={onMoveStart}
|
||||
onRegionChangeComplete={(next) =>
|
||||
onSettled({ latitude: next.latitude, longitude: next.longitude })
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={styles.pinLayer} pointerEvents="none">
|
||||
<Image source={icons.pin} style={styles.pin} resizeMode="contain" />
|
||||
<View style={styles.dot} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { useT } from "@/lib/i18n";
|
||||
import type { PinAdjusterProps } from "@/components/pin-adjuster";
|
||||
|
||||
// react-native-maps does not support web, same as components/map.web.tsx.
|
||||
// The screen around this still works — the rider just can't drag a pin — so
|
||||
// the stub reports nothing and leaves whatever coordinate they arrived with
|
||||
// intact, rather than blocking the flow on a platform used only for testing.
|
||||
export const PinAdjuster = (_props: PinAdjusterProps) => {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-general-100 dark:bg-neutral-900">
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-center font-JakartaMedium px-8">
|
||||
{t("components.map.webUnavailable")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type * as ImagePicker from "expo-image-picker";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { alertPermissionDenied } from "@/lib/capture-permission";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { loadImagePicker } from "@/lib/image-picker";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
type PhotoResponse = { data: { photo: string; attached: boolean } };
|
||||
|
||||
/**
|
||||
* The driver's own photo — the one a rider sees against their name in the list
|
||||
* of offers, and checks the arriving driver against.
|
||||
*
|
||||
* Deliberately not the document scanner: this photo is never read by OCR, it
|
||||
* is cropped square because it is rendered in a circle everywhere, and it
|
||||
* opens the front camera because it is a picture of a person rather than a
|
||||
* piece of paper.
|
||||
*
|
||||
* Uploading attaches it immediately for a driver who already has a profile, so
|
||||
* replacing a bad photo is one tap. During onboarding there is no profile row
|
||||
* yet, so the caller keeps the returned name and sends it with the submission.
|
||||
*/
|
||||
export const ProfilePhotoPicker = ({
|
||||
current,
|
||||
onUploaded,
|
||||
}: {
|
||||
/** The photo already on the profile, if any. */
|
||||
current?: string | null;
|
||||
onUploaded: (photo: string) => void;
|
||||
}) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// A just-taken photo wins over what the server has, so the driver sees the
|
||||
// result of their own tap rather than the picture it replaced.
|
||||
const shown = preview ?? driverPhotoUri(current) ?? null;
|
||||
|
||||
const upload = async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
if (!asset.base64) {
|
||||
Alert.alert(t("driver.photo.errorTitle"), t("driver.photo.errorBody"));
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data } = (await fetchAPI("/(api)/driver/photo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_base64: asset.base64 }),
|
||||
})) as PhotoResponse;
|
||||
|
||||
setPreview(asset.uri);
|
||||
onUploaded(data.photo);
|
||||
} catch (err) {
|
||||
console.log("[DRIVER_PHOTO]: ", err);
|
||||
|
||||
const code =
|
||||
err instanceof ApiError
|
||||
? (err.body?.code as string | undefined)
|
||||
: undefined;
|
||||
|
||||
Alert.alert(
|
||||
t("driver.photo.errorTitle"),
|
||||
code === "IMAGE_TOO_LARGE"
|
||||
? t("driver.photo.errorTooLarge")
|
||||
: code === "PHOTO_RATE_LIMIT"
|
||||
? t("driver.photo.errorRateLimit")
|
||||
: code === "UNSUPPORTED_IMAGE"
|
||||
? t("driver.photo.errorUnsupported")
|
||||
: t("driver.photo.errorBody"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Camera only — deliberately no gallery option.
|
||||
//
|
||||
// This photo is the rider's check that the person who pulled up is the
|
||||
// person the app sent them, so it has to be a picture of whoever is holding
|
||||
// the phone right now. Letting it come from the gallery would let a driver
|
||||
// register with someone else's face, or a photo of a photo, and nothing
|
||||
// downstream could tell the difference. It is not proof of identity — a
|
||||
// determined faker can point the camera at a printout — but it removes the
|
||||
// effortless version of that, and it keeps the photo current.
|
||||
const capture = async () => {
|
||||
if (busy) return;
|
||||
|
||||
// Loaded on demand — see lib/image-picker. On a binary built before
|
||||
// expo-image-picker was added this is the difference between one button
|
||||
// not working and the app not starting.
|
||||
const picker = loadImagePicker();
|
||||
if (!picker) {
|
||||
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything that touches the picker is wrapped: the availability check
|
||||
// above should make a missing native module impossible, but a driver must
|
||||
// never be shown a raw "Cannot find native module" either way.
|
||||
let result: ImagePicker.ImagePickerResult;
|
||||
|
||||
try {
|
||||
const permission = await picker.requestCameraPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
alertPermissionDenied(permission, {
|
||||
title: t("driver.photo.permissionTitle"),
|
||||
message: t("driver.photo.permissionCamera"),
|
||||
blocked: t("driver.photo.permissionCameraBlocked"),
|
||||
openSettings: t("common.openSettings"),
|
||||
cancel: t("common.cancel"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No crop step: one tap, done. Every surface renders this in a circle
|
||||
// with a centre crop anyway, and a selfie is already centred on the face.
|
||||
result = await picker.launchCameraAsync({
|
||||
mediaTypes: picker.MediaTypeOptions.Images,
|
||||
quality: 0.7,
|
||||
base64: true,
|
||||
exif: false,
|
||||
cameraType: picker.CameraType.front,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("[DRIVER_PHOTO_CAMERA]: ", error);
|
||||
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
|
||||
await upload(result.assets[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="items-center mb-6">
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture()}
|
||||
disabled={busy}
|
||||
className="w-28 h-28 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center overflow-hidden border-2 border-primary-500"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color="#0286ff" />
|
||||
) : shown ? (
|
||||
<Image
|
||||
source={{ uri: shown }}
|
||||
className="w-28 h-28"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<MaterialCommunityIcons
|
||||
name="camera-plus-outline"
|
||||
size={30}
|
||||
color={isDark ? "#9ca3af" : "#858585"}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-sm font-JakartaBold text-black dark:text-white mt-3">
|
||||
{t("driver.photo.title")}
|
||||
</Text>
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-1 px-6">
|
||||
{t("driver.photo.hint")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture()}
|
||||
disabled={busy}
|
||||
className="flex-row items-center rounded-full bg-primary-500 py-2.5 px-5 mt-3"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={15}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<Text className="text-white font-JakartaBold text-xs ml-1.5">
|
||||
{shown ? t("driver.photo.retake") : t("driver.photo.take")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import { Image, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
// The post-trip rating prompt, shared by both apps: a rider rates their driver
|
||||
// and a driver rates their rider through the same endpoint, which infers who
|
||||
// is rating from the caller's role on the ride. Both sides get the same sheet
|
||||
// so the two directions can't drift apart.
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
rideId: number;
|
||||
/** Who is being rated — only used for the copy. */
|
||||
subjectName?: string | null;
|
||||
subjectAvatar?: string | null;
|
||||
/** Rider-facing copy differs from driver-facing copy. */
|
||||
audience: "rider" | "driver";
|
||||
onDone: () => void;
|
||||
/** Called on "not now"; omit to make the rating unskippable. */
|
||||
onSkip?: () => void;
|
||||
};
|
||||
|
||||
export const RatingSheet = ({
|
||||
visible,
|
||||
rideId,
|
||||
subjectName,
|
||||
subjectAvatar,
|
||||
audience,
|
||||
onDone,
|
||||
onSkip,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
const [stars, setStars] = useState(0);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (stars < 1) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fetchAPI(`/(api)/ride/${rideId}/rate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rating: stars,
|
||||
comment: comment.trim() || null,
|
||||
}),
|
||||
});
|
||||
onDone();
|
||||
} catch (err) {
|
||||
console.log("[RATE_RIDE]: ", err);
|
||||
setError(t("rating.error"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactNativeModal isVisible={visible} onBackdropPress={onSkip}>
|
||||
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
|
||||
{subjectAvatar ? (
|
||||
<Image
|
||||
source={{ uri: driverPhotoUri(subjectAvatar) }}
|
||||
className="w-16 h-16 rounded-full self-center mb-3"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Text className="text-xl font-JakartaBold text-center text-black dark:text-white">
|
||||
{audience === "rider"
|
||||
? t("rating.rateDriverTitle", { name: subjectName ?? "" })
|
||||
: t("rating.rateRiderTitle", { name: subjectName ?? "" })}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 text-center mt-1">
|
||||
{t("rating.subtitle")}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row justify-center gap-x-2 my-5">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<TouchableOpacity
|
||||
key={value}
|
||||
onPress={() => setStars(value)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
accessibilityLabel={t("rating.starLabel", { n: value })}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={value <= stars ? "star" : "star-outline"}
|
||||
size={38}
|
||||
color={
|
||||
value <= stars ? "#f5b301" : isDark ? "#525252" : "#d4d4d4"
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
value={comment}
|
||||
onChangeText={setComment}
|
||||
placeholder={t("rating.commentPlaceholder")}
|
||||
placeholderTextColor={isDark ? "#737373" : "#858585"}
|
||||
multiline
|
||||
maxLength={500}
|
||||
className="bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white rounded-2xl px-4 py-3 font-Jakarta text-[15px] min-h-[72px]"
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-rose-500 text-sm text-center mt-3">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<CustomButton
|
||||
title={submitting ? t("common.saving") : t("rating.submit")}
|
||||
onPress={submit}
|
||||
disabled={submitting || stars < 1}
|
||||
className={`mt-5 ${stars < 1 ? "opacity-50" : ""}`}
|
||||
/>
|
||||
|
||||
{onSkip ? (
|
||||
<TouchableOpacity onPress={onSkip} className="py-3 mt-1">
|
||||
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("rating.notNow")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,29 @@ import { tr } from "@/lib/i18n";
|
||||
import { formatDate, formatTime } from "@/lib/utils";
|
||||
import type { Ride } from "@/types/type";
|
||||
|
||||
// How a finished ride ended. The history list used to render every ride
|
||||
// identically — a cancelled trip showed the same driver, the same fare and, on
|
||||
// a card ride, the same green "Paid by card" as one that actually happened, so
|
||||
// a rider scrolling their history saw cancellations as completed journeys.
|
||||
// The outcome is now the first thing on the card.
|
||||
const OUTCOME = {
|
||||
completed: {
|
||||
labelKey: "components.rideCard.outcomeCompleted",
|
||||
text: "text-emerald-600 dark:text-emerald-400",
|
||||
chip: "bg-emerald-500/10",
|
||||
},
|
||||
cancelled: {
|
||||
labelKey: "components.rideCard.outcomeCancelled",
|
||||
text: "text-rose-500",
|
||||
chip: "bg-rose-500/10",
|
||||
},
|
||||
expired: {
|
||||
labelKey: "components.rideCard.outcomeExpired",
|
||||
text: "text-amber-600 dark:text-amber-400",
|
||||
chip: "bg-amber-500/10",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const RideCard = ({ ride }: { ride: Ride }) => {
|
||||
const {
|
||||
destination_latitude,
|
||||
@@ -15,11 +38,54 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
|
||||
ride_time,
|
||||
driver,
|
||||
payment_status,
|
||||
status,
|
||||
cancelled_by,
|
||||
cancellation_reason,
|
||||
} = ride;
|
||||
|
||||
const outcome = OUTCOME[status as keyof typeof OUTCOME] ?? null;
|
||||
const didNotHappen = status === "cancelled" || status === "expired";
|
||||
|
||||
// A cancelled or expired ride never had a driver assigned in most cases, and
|
||||
// the LEFT JOIN hands back an object of nulls — which rendered as an empty
|
||||
// gap where a name should be.
|
||||
const driverName = [driver?.first_name, driver?.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-center bg-white dark:bg-neutral-900 rounded-lg shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40 mb-3">
|
||||
<View className="flex flex-col items-center justify-center p-3">
|
||||
{outcome ? (
|
||||
<View className="flex flex-row items-center justify-between w-full mb-3">
|
||||
<View className={`rounded-full px-3 py-1 ${outcome.chip}`}>
|
||||
<Text className={`text-xs font-JakartaBold ${outcome.text}`}>
|
||||
{tr(outcome.labelKey)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Who ended it, and why — the two things a rider looking back at
|
||||
a cancelled trip actually wants to know. */}
|
||||
{didNotHappen && cancelled_by ? (
|
||||
<Text
|
||||
className="text-[11px] font-JakartaMedium text-gray-500 dark:text-neutral-400 flex-1 text-right ml-2"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{cancelled_by === "system"
|
||||
? tr("components.rideCard.cancelledBySystem")
|
||||
: tr(
|
||||
cancelled_by === "driver"
|
||||
? "components.rideCard.cancelledByDriver"
|
||||
: "components.rideCard.cancelledByYou",
|
||||
)}
|
||||
{cancellation_reason
|
||||
? ` · ${tr(`cancelSheet.reasons.${cancellation_reason}`)}`
|
||||
: ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Image
|
||||
source={{
|
||||
@@ -69,7 +135,7 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
|
||||
</Text>
|
||||
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{driver.first_name} {driver.last_name}
|
||||
{driverName || tr("components.rideCard.noDriver")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -98,14 +164,35 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
|
||||
{tr("components.rideCard.paymentStatus")}
|
||||
</Text>
|
||||
|
||||
{/* A ride that never happened has no payment worth reporting as
|
||||
successful. A cash ride simply wasn't collected; a card ride
|
||||
that was charged before cancellation is called out as owed a
|
||||
refund rather than shown as a cheerful green "Paid". */}
|
||||
<Text
|
||||
className={`font-JakartaMedium capitalize text-xs ${payment_status === "paid" ? "text-emerald-500 dark:text-emerald-400" : "text-gray-500 dark:text-neutral-400"}`}
|
||||
className={`font-JakartaMedium capitalize text-xs ${
|
||||
didNotHappen
|
||||
? payment_status === "paid"
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-gray-500 dark:text-neutral-400"
|
||||
: payment_status === "paid" ||
|
||||
payment_status === "cash_collected"
|
||||
? "text-emerald-500 dark:text-emerald-400"
|
||||
: "text-gray-500 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{payment_status === "cash"
|
||||
? tr("components.rideCard.paymentCash")
|
||||
: payment_status === "paid"
|
||||
? tr("components.rideCard.paymentPaid")
|
||||
: tr("components.rideCard.paymentOther", { status: payment_status })}
|
||||
{didNotHappen
|
||||
? payment_status === "paid"
|
||||
? tr("components.rideCard.paymentRefundDue")
|
||||
: tr("components.rideCard.paymentNotCharged")
|
||||
: payment_status === "cash"
|
||||
? tr("components.rideCard.paymentCash")
|
||||
: payment_status === "cash_collected"
|
||||
? tr("components.rideCard.paymentCashCollected")
|
||||
: payment_status === "paid"
|
||||
? tr("components.rideCard.paymentPaid")
|
||||
: tr("components.rideCard.paymentOther", {
|
||||
status: payment_status,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { router } from "expo-router";
|
||||
import { useRef, type PropsWithChildren } from "react";
|
||||
import { Image, Text, TouchableOpacity, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { tr } from "@/lib/i18n";
|
||||
@@ -22,6 +23,7 @@ export const RideLayout = ({
|
||||
}: PropsWithChildren<RideLayoutProps>) => {
|
||||
const bottomSheetRef = useRef<BottomSheet>(null);
|
||||
const { isDark } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView>
|
||||
@@ -57,14 +59,21 @@ export const RideLayout = ({
|
||||
backgroundColor: isDark ? "#525252" : "#d4d4d4",
|
||||
}}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
{/* Scrollable rather than a plain view: the keyboard takes half the
|
||||
screen while the rider is typing an address, and everything below
|
||||
the field it covers — the fare, "Find now" — was simply out of
|
||||
reach until they dismissed it. The bottom inset keeps the button
|
||||
clear of the Android gesture bar. */}
|
||||
<BottomSheetScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
paddingBottom: 20 + insets.bottom,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{children}
|
||||
</BottomSheetView>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useServiceStore } from "@/store";
|
||||
import { useServiceAvailability } from "@/lib/use-service-availability";
|
||||
import { useLocationStore, useServiceStore } from "@/store";
|
||||
|
||||
/**
|
||||
* Service picker: Car / Moto / Courier / My Car.
|
||||
@@ -12,11 +13,23 @@ import { useServiceStore } from "@/store";
|
||||
* services, anything off-screen is a service riders won't discover. Selection
|
||||
* styling matches the role picker on sign-up so the two read as the same
|
||||
* control.
|
||||
*
|
||||
* Each tile also carries live availability. The map only ever draws the
|
||||
* selected service, so picking one with nobody on it produced an empty map and
|
||||
* no explanation — the rider couldn't tell "no drivers tonight" from "no motos,
|
||||
* but four cars are around the corner". Showing the count on the tile makes
|
||||
* that visible before they choose, instead of after they've given up.
|
||||
*/
|
||||
export const ServiceSelector = () => {
|
||||
const { service, setService } = useServiceStore();
|
||||
const { userLatitude, userLongitude } = useLocationStore();
|
||||
const t = useT();
|
||||
|
||||
const { counts, loading } = useServiceAvailability(
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
|
||||
const selected = SERVICES.find((item) => item.id === service);
|
||||
|
||||
return (
|
||||
@@ -52,6 +65,23 @@ export const ServiceSelector = () => {
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Text>
|
||||
|
||||
{/* Availability. Hidden until the first count lands so the tiles
|
||||
don't flash "none nearby" while the request is still out. */}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`mt-0.5 text-[10px] font-JakartaMedium ${
|
||||
loading
|
||||
? "text-transparent"
|
||||
: counts[item.id] > 0
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-general-200 dark:text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{counts[item.id] > 0
|
||||
? t("services.nearbyCount", { n: counts[item.id] })
|
||||
: t("services.noneNearby")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from "@/lib/theme";
|
||||
|
||||
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
|
||||
|
||||
type RightKind = "chevron" | "switch" | "value" | "none";
|
||||
type RightKind = "chevron" | "switch" | "value" | "check" | "none";
|
||||
|
||||
type SettingsRowProps = {
|
||||
icon: IconName;
|
||||
@@ -14,6 +14,8 @@ type SettingsRowProps = {
|
||||
right?: RightKind;
|
||||
/** For `right: "value"` — the string shown on the trailing side. */
|
||||
value?: string;
|
||||
/** For `right: "check"` — shows a blue check when true, nothing when false. */
|
||||
selected?: boolean;
|
||||
/** For `right: "switch"`. */
|
||||
switchValue?: boolean;
|
||||
onSwitchChange?: (value: boolean) => void;
|
||||
@@ -29,13 +31,14 @@ export const SettingsRow = ({
|
||||
subtitle,
|
||||
right = "none",
|
||||
value,
|
||||
selected,
|
||||
switchValue,
|
||||
onSwitchChange,
|
||||
onPress,
|
||||
danger = false,
|
||||
}: SettingsRowProps) => {
|
||||
const { isDark } = useTheme();
|
||||
const interactive = right === "chevron" || right === "value";
|
||||
const interactive = right === "chevron" || right === "value" || right === "check";
|
||||
|
||||
const content = (
|
||||
<View className="flex-row items-center py-3.5">
|
||||
@@ -83,6 +86,10 @@ export const SettingsRow = ({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{right === "check" && selected ? (
|
||||
<MaterialCommunityIcons name="check" size={22} color="#0286ff" />
|
||||
) : null}
|
||||
|
||||
{right === "chevron" ? (
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
|
||||
Reference in New Issue
Block a user