Waseel: driver capture, chat/calls, dispatch, and session fixes

Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+146
View File
@@ -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;
};
+100
View File
@@ -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;
+106
View File
@@ -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>
);
};
+278
View File
@@ -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>
);
};
+7 -2
View File
@@ -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>
);
+320
View File
@@ -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>
);
};
+15 -4
View File
@@ -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>
+21 -16
View File
@@ -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
View File
@@ -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>
);
};
+2 -1
View File
@@ -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 (
+177
View File
@@ -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>
);
};
+108
View File
@@ -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>
);
};
+81
View File
@@ -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>
);
};
+99
View File
@@ -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>
);
};
+20
View File
@@ -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>
);
};
+199
View File
@@ -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>
);
};
+140
View File
@@ -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>
);
};
+94 -7
View File
@@ -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>
+14 -5
View File
@@ -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>
+31 -1
View File
@@ -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>
);
})}
+9 -2
View File
@@ -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"