Files
waseel/app/(root)/driver-home.tsx
T
KrikoriosandClaude Opus 5 8807ff41c5 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>
2026-08-26 02:17:55 +03:00

1986 lines
69 KiB
TypeScript

import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
Linking,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import ReactNativeModal from "react-native-modal";
import { SafeAreaView } from "react-native-safe-area-context";
import { driverPhotoUri } from "@/lib/driver-photo";
import { CancelSheet } from "@/components/cancel-sheet";
import { CustomButton } from "@/components/custom-button";
import {
DocumentScanner,
type DocumentType,
type ScannedFields,
} from "@/components/document-scanner";
import { Map } from "@/components/map";
import { PickupCodeSheet } from "@/components/pickup-code-sheet";
import { ProfilePhotoPicker } from "@/components/profile-photo-picker";
import { RatingSheet } from "@/components/rating-sheet";
import { icons, images } from "@/constants";
import { REQUEST_TTL_SECONDS } from "@/constants/dispatch";
import { SERVICES, type ServiceId } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import {
ensureNotificationPermission,
registerForPush,
} from "@/lib/notifications";
import { useSession } from "@/lib/session";
import { useTheme } from "@/lib/theme";
import { ensureMicPermission } from "@/lib/use-call";
import { useDriverLocation } from "@/lib/use-driver-location";
import { formatTime, haversine } from "@/lib/utils";
// Poll cadence for the driver dashboard (offers / active ride / earnings).
const POLL_MS = 4000;
type ApprovalStatus = "pending" | "approved" | "rejected" | "suspended";
type Profile = {
id: number;
first_name: string;
last_name: string;
profile_image_url: string | null;
car_image_url: string | null;
car_seats: number;
rating: number;
rating_count: number;
service: ServiceId;
online: boolean;
car_model: string | null;
approval_status: ApprovalStatus;
rejection_reason: string | null;
license_number: string | null;
license_expiry: string | null;
plate_number: string | null;
/** Stored scan names, or null where nothing has been uploaded. */
license_image_url: string | null;
id_image_url: string | null;
vehicle_reg_image_url: string | null;
};
// The credentials a driver submits for vetting: the numbers an owner checks a
// driver against before letting them near a rider. They are normally read off
// a scan rather than typed, but the driver owns every value in the end — the
// scan prefills the form, it does not submit it.
type Credentials = {
license_number: string;
license_expiry: string;
national_id: string;
plate_number: string;
};
const CREDENTIAL_KEYS = [
"license_number",
"license_expiry",
"national_id",
"plate_number",
] as const;
/**
* Credential fields the resubmit form seeds from the rejected profile. They
* are treated as replaceable by a fresh scan — see CredentialCapture's
* `prefilled`. national_id is not among them: the API never sends it back.
*/
const PREFILLED_ON_RESUBMIT = [
"license_number",
"license_expiry",
"plate_number",
] as const satisfies readonly (keyof Credentials)[];
const EMPTY_CREDENTIALS: Credentials = {
license_number: "",
license_expiry: "",
national_id: "",
plate_number: "",
};
// The stored scans that go up with a submission, keyed the way the API expects
// them. Null means "nothing scanned in this session" — which on a resubmission
// is different from "nothing on file", since the profile may already have one.
type DocumentRefs = {
license_document: string | null;
id_document: string | null;
vehicle_reg_document: string | null;
};
const EMPTY_DOCUMENTS: DocumentRefs = {
license_document: null,
id_document: null,
vehicle_reg_document: null,
};
const DOCUMENT_KEY: Record<DocumentType, keyof DocumentRefs> = {
license: "license_document",
id: "id_document",
vehicle_reg: "vehicle_reg_document",
};
// An open request on the board. Under the broadcast model this is not
// addressed to this driver — it is a job several of them can see and any of
// them can volunteer for, which is why it carries how many have already
// offered and whether this driver is one of them.
type OpenRequest = {
ride_id: number;
created_at: string;
origin_address: string;
destination_address: string;
origin_latitude: number;
origin_longitude: number;
ride_time: number;
fare_price: number;
/** What the driver keeps after the platform fee. */
payout_cents: number;
service: string;
rider_name: string | null;
rider_rating: number | null;
/** This driver's live offer on it, or null if they haven't offered. */
my_offer_id: number | null;
/** How many drivers are in the running, this one included. */
offer_count: number;
/** Metres from this driver's last position to the pickup. */
pickup_distance_m: number;
};
type ActiveRide = {
ride_id: number;
status: string;
service: string;
payment_status: string;
origin_address: string;
destination_address: string;
origin_latitude: number;
origin_longitude: number;
destination_latitude: number;
destination_longitude: number;
ride_time: number;
fare_price: number;
/** What the driver keeps after the platform fee. */
payout_cents: number;
arrived_at: string | null;
rider_name: string | null;
rider_rating: number | null;
};
type Dashboard = {
now: string;
requests: OpenRequest[];
active: ActiveRide | null;
recent: { ride_id: number; fare_price: number; service: string }[];
earnings: number;
platform_fees: number;
cash_collected: number;
cash_owed: number;
/** Cash commission this driver is holding on the company's behalf. */
owes_company: number;
/** Card payouts the company still owes this driver. */
owed_to_driver: number;
pending_rating: { ride_id: number; rider_name: string | null } | null;
};
const DriverHome = () => {
const { signOut, user } = useSession();
const { isDark } = useTheme();
const t = useT();
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<Profile | null>(null);
const [online, setOnline] = useState(false);
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [busy, setBusy] = useState(false);
const [codeOpen, setCodeOpen] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
const [cancelOpen, setCancelOpen] = useState(false);
// Rating prompts are dismissible; remember which rides were dismissed this
// session so the next poll doesn't re-open the sheet the driver just closed.
const [ratingSkipped, setRatingSkipped] = useState<number[]>([]);
// Retaking the profile photo. A driver whose photo turned out dark or
// half-cropped is the one rider-facing detail they can't otherwise fix.
const [photoOpen, setPhotoOpen] = useState(false);
const loadProfile = useCallback(async () => {
try {
const res = await fetchAPI("/(api)/driver/profile");
const p = res.data as Profile;
setProfile(p);
setOnline(p.online);
} catch (err) {
// 403 with code ONBOARD means no profile yet — show the onboarding form.
if (err instanceof ApiError && err.status === 403) {
setProfile(null);
} else {
console.log("[DRIVER_PROFILE_LOAD]: ", err);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadProfile();
}, [loadProfile]);
// Keep the location heartbeat running only while the driver is online and
// has completed onboarding. Also gives us the driver's own last-known
// position, so the active-ride card can show it next to the rider's pickup.
const driverCoords = useDriverLocation(online && profile !== null);
// Poll the dashboard while online. useCallback keeps the fetcher stable so the
// interval effect doesn't re-subscribe on every render.
// Difference between the server's clock and this phone's, refreshed on every
// poll. The offer countdown is drawn against server time because that's the
// clock dispatch expires offers on — a phone a few seconds out would
// otherwise show a timer that runs out early or lingers past the offer.
const clockOffset = useRef(0);
// Rides this driver has a live offer on. Offering is a bid, not a booking:
// the rider may pick someone else and the card simply vanishes on the next
// poll, which is the most confusing thing that can happen on this screen if
// nobody says why.
const myOffers = useRef<number[]>([]);
const fetchDashboard = useCallback(async () => {
try {
const res = await fetchAPI("/(api)/driver/rides");
const data = res.data as Dashboard;
if (data.now) clockOffset.current = Date.parse(data.now) - Date.now();
const stillListed = new Set(data.requests.map((r) => r.ride_id));
const wonId = data.active?.ride_id ?? null;
const lost = myOffers.current.filter(
(rideId) => !stillListed.has(rideId) && rideId !== wonId,
);
if (lost.length > 0) {
Alert.alert(
t("driver.offerCard.lostTitle"),
t("driver.offerCard.lostBody"),
);
}
myOffers.current = data.requests
.filter((r) => r.my_offer_id !== null)
.map((r) => r.ride_id);
setDashboard(data);
} catch (err) {
console.log("[DRIVER_DASHBOARD_POLL]: ", err);
}
}, [t]);
useEffect(() => {
if (!online || !profile) return;
void fetchDashboard();
const timer = setInterval(() => void fetchDashboard(), POLL_MS);
return () => clearInterval(timer);
}, [online, profile, fetchDashboard]);
// Prime the mic permission as soon as the Message/Call buttons appear on
// the active-ride card, so the OS prompt lands here instead of mid-handshake
// after the driver has already tapped Call.
const activeRideId = dashboard?.active?.ride_id ?? null;
useEffect(() => {
if (activeRideId !== null) void ensureMicPermission();
}, [activeRideId]);
const toggleOnline = async () => {
if (!profile) return;
const next = !online;
setBusy(true);
try {
// Going online is the first moment there's a concrete reason to
// interrupt this driver, so the notification prompt lands here rather
// than at app start where it would read as a random demand. Registering
// for remote push is attempted at the same time; it is a no-op until
// push credentials are configured.
if (next) {
await ensureNotificationPermission();
void registerForPush();
}
await fetchAPI("/(api)/driver/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ online: next }),
});
setOnline(next);
setProfile({ ...profile, online: next });
if (!next) setDashboard(null);
} catch (err) {
console.log("[DRIVER_TOGGLE]: ", err);
// 403 means the profile lost its approval while the app was open (an
// owner suspended it). Reload so the review screen takes over rather
// than leaving the driver tapping a toggle that will never work.
if (err instanceof ApiError && err.status === 403) {
await loadProfile();
return;
}
// The server refuses to take a driver offline mid-ride — going dark on a
// rider who is waiting for you is the one case worth blocking outright.
Alert.alert(
t("driver.home.alertErrorTitle"),
err instanceof ApiError && err.status === 409
? t("driver.home.alertOfflineBlocked")
: t("driver.home.alertToggleBody"),
);
} finally {
setBusy(false);
}
};
// Volunteer for a request, or take the offer back. Neither is an
// assignment: the rider decides, and until they do this driver stays on the
// board and free to offer on other jobs.
const respond = async (
request: OpenRequest,
action: "offer" | "withdraw",
) => {
setBusy(true);
try {
await fetchAPI(`/(api)/ride/${request.ride_id}/offer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
// Don't warn about a request disappearing that this driver just walked
// away from themselves.
if (action === "withdraw") {
myOffers.current = myOffers.current.filter(
(id) => id !== request.ride_id,
);
}
await fetchDashboard();
} catch (err) {
console.log("[DRIVER_OFFER]: ", err);
// A 409 is the ordinary outcome of several drivers wanting the same job:
// somebody was picked while this one was reading it.
if (err instanceof ApiError && err.status === 409) {
Alert.alert(
t("driver.offerCard.lostTitle"),
t("driver.offerCard.lostBody"),
);
myOffers.current = myOffers.current.filter(
(id) => id !== request.ride_id,
);
await fetchDashboard();
return;
}
Alert.alert(
t("driver.activeRide.alertErrorTitle"),
action === "offer"
? t("driver.offerCard.alertOfferBody")
: t("driver.offerCard.alertWithdrawBody"),
);
} finally {
setBusy(false);
}
};
// Generic state push for transitions the driver can make unilaterally
// ("I'm at the pickup point", "the trip is done").
const advance = async (
rideId: number,
status: "arrived" | "completed",
extra?: Record<string, unknown>,
) => {
setBusy(true);
try {
await fetchAPI(`/(api)/ride/${rideId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status, ...extra }),
});
await fetchDashboard();
} catch (err) {
console.log("[DRIVER_ADVANCE]: ", err);
Alert.alert(
t("driver.activeRide.alertErrorTitle"),
t("driver.activeRide.alertUpdateBody"),
);
} finally {
setBusy(false);
}
};
// Starting the trip needs the rider's pickup code, so it goes through the
// code sheet rather than a plain state push.
const startTrip = async (rideId: number, code: string) => {
setBusy(true);
setCodeError(null);
try {
await fetchAPI(`/(api)/ride/${rideId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "en_route", pickup_code: code }),
});
setCodeOpen(false);
await fetchDashboard();
} catch (err) {
console.log("[DRIVER_START_TRIP]: ", err);
// 403 is specifically "that code doesn't match" — keep the sheet open so
// the driver can re-read it off the rider's phone and try again.
setCodeError(
err instanceof ApiError && err.status === 403
? t("pickupCode.wrongCode")
: t("driver.activeRide.alertUpdateBody"),
);
} finally {
setBusy(false);
}
};
// Completing a cash ride also settles the money: the driver confirms they
// took the fare, which is what turns it from owed into collected.
const completeRide = (ride: ActiveRide) => {
if (ride.payment_status !== "cash") {
void advance(ride.ride_id, "completed");
return;
}
Alert.alert(
t("driver.activeRide.cashConfirmTitle"),
t("driver.activeRide.cashConfirmBody", {
amount: (ride.fare_price / 100).toFixed(2),
}),
[
// "Not collected" still completes the trip — the rider has been
// dropped off either way. It leaves the fare marked as owed so it
// shows up as an unsettled balance instead of vanishing.
{
text: t("driver.activeRide.cashNotCollected"),
onPress: () => void advance(ride.ride_id, "completed"),
},
{
text: t("driver.activeRide.cashCollected"),
onPress: () =>
void advance(ride.ride_id, "completed", { cash_collected: true }),
},
],
);
};
// Only allowed before the trip starts ('accepted' / 'arrived'): once en
// route the driver already has the rider, so aborting is "complete the
// trip", not "cancel" it.
const cancelRide = async (rideId: number, reason: string) => {
setBusy(true);
try {
await fetchAPI(`/(api)/ride/${rideId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "cancelled", reason }),
});
setCancelOpen(false);
await fetchDashboard();
} catch (err) {
console.log("[DRIVER_CANCEL]: ", err);
Alert.alert(
t("driver.activeRide.alertErrorTitle"),
t("driver.activeRide.alertCancelBody"),
);
} finally {
setBusy(false);
}
};
if (loading) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<ActivityIndicator
size="large"
color={isDark ? "#0286ff" : "#0286ff"}
/>
</SafeAreaView>
);
}
if (!profile) {
return (
<Onboarding
onCreated={loadProfile}
signOut={signOut}
userName={user?.name}
/>
);
}
// A profile is not a licence to drive. Until an owner has reviewed the
// driver's credentials the dashboard is replaced by the review screen —
// there is nothing here they can act on, and the server would refuse the
// online toggle anyway.
if (profile.approval_status !== "approved") {
return (
<ReviewStatus
profile={profile}
onRefresh={loadProfile}
signOut={signOut}
/>
);
}
const earnings = dashboard?.earnings ?? 0;
const rideCount = dashboard?.recent.length ?? 0;
const cashCollected = dashboard?.cash_collected ?? 0;
const cashOwed = dashboard?.cash_owed ?? 0;
const platformFees = dashboard?.platform_fees ?? 0;
const owesCompany = dashboard?.owes_company ?? 0;
const owedToDriver = dashboard?.owed_to_driver ?? 0;
const pendingRating = dashboard?.pending_rating ?? null;
return (
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingBottom: 40 }}
>
<View className="flex-row items-center justify-between my-5">
<View className="flex-row items-center flex-1">
<TouchableOpacity
onPress={() => setPhotoOpen(true)}
className="w-12 h-12 rounded-full bg-white dark:bg-neutral-900 items-center justify-center overflow-hidden mr-3"
>
{profile.profile_image_url ? (
<Image
source={{ uri: driverPhotoUri(profile.profile_image_url) }}
className="w-12 h-12"
resizeMode="cover"
alt={t("driver.photo.title")}
/>
) : (
<MaterialCommunityIcons
name="camera-plus-outline"
size={20}
color={isDark ? "#9ca3af" : "#858585"}
/>
)}
</TouchableOpacity>
<View className="flex-1">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{t("driver.home.driverMode")}
</Text>
{/* A driver's own rating drives whether they keep working here, and
it was being fetched but never shown. */}
<Text className="text-xs font-JakartaMedium text-general-200 dark:text-neutral-400 mt-0.5">
{Number(profile.rating).toFixed(1)}
{profile.rating_count > 0
? ` · ${t("driver.home.ratingCount", {
n: String(profile.rating_count),
})}`
: ` · ${t("driver.home.ratingNew")}`}
</Text>
</View>
</View>
<TouchableOpacity
onPress={signOut}
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center"
>
<Image
source={icons.out}
className="w-4 h-4"
alt={t("driver.home.signOutAlt")}
/>
</TouchableOpacity>
</View>
{/* Online / offline toggle */}
<TouchableOpacity
onPress={toggleOnline}
disabled={busy}
className={`rounded-2xl p-5 items-center mb-4 ${
online ? "bg-emerald-500" : "bg-neutral-700 dark:bg-neutral-800"
}`}
>
<Text className="text-white text-lg font-JakartaBold">
{online ? t("driver.home.online") : t("driver.home.goOnline")}
</Text>
</TouchableOpacity>
{/* Earnings summary */}
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-4 flex-row justify-between">
<View>
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.todaysEarnings")}
</Text>
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
${(earnings / 100).toFixed(2)}
</Text>
{/* Says where the difference went. A driver who charged $20 in
fares and sees $16 needs the missing $4 accounted for on the
same screen, or the number reads as an error. */}
{platformFees > 0 ? (
<Text className="text-[11px] font-JakartaMedium text-general-200 dark:text-neutral-500 mt-0.5">
{t("driver.home.afterFee", {
fee: (platformFees / 100).toFixed(2),
})}
</Text>
) : null}
</View>
<View className="items-end">
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.completedToday")}
</Text>
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{rideCount}
</Text>
</View>
</View>
{/* Cash in hand. Separated from earnings because it's money the driver
is holding on the platform's behalf, and the figure they'll be
reconciled against at the end of the day. */}
{cashCollected > 0 ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl px-4 py-3 mb-4 flex-row justify-between items-center">
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.cashInHand")}
</Text>
<Text className="font-JakartaBold text-amber-600 dark:text-amber-400">
${(cashCollected / 100).toFixed(2)}
</Text>
</View>
) : null}
{/* The running balance with the company, both directions. Kept
separate from today's earnings because it doesn't reset at
midnight — commission a driver is holding from Tuesday is still
owed on Friday, and a driver who can't see it has no way to know
what they'll be asked for. */}
{owesCompany > 0 || owedToDriver > 0 ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl px-4 py-3 mb-4">
{owesCompany > 0 ? (
<View className="flex-row justify-between items-center">
<View className="flex-1 pr-2">
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.owesCompany")}
</Text>
<Text className="text-[11px] text-general-200 dark:text-neutral-500 font-Jakarta mt-0.5">
{t("driver.home.owesCompanyHint")}
</Text>
</View>
<Text className="font-JakartaBold text-amber-600 dark:text-amber-400">
${(owesCompany / 100).toFixed(2)}
</Text>
</View>
) : null}
{owesCompany > 0 && owedToDriver > 0 ? (
<View className="border-t border-neutral-100 dark:border-neutral-800 my-2.5" />
) : null}
{owedToDriver > 0 ? (
<View className="flex-row justify-between items-center">
<View className="flex-1 pr-2">
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.owedToDriver")}
</Text>
<Text className="text-[11px] text-general-200 dark:text-neutral-500 font-Jakarta mt-0.5">
{t("driver.home.owedToDriverHint")}
</Text>
</View>
<Text className="font-JakartaBold text-emerald-600 dark:text-emerald-400">
${(owedToDriver / 100).toFixed(2)}
</Text>
</View>
) : null}
</View>
) : null}
{/* Fares that were never collected. These no longer count towards the
earnings headline, so they're shown here instead of silently
inflating a number the driver won't be paid. */}
{cashOwed > 0 ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl px-4 py-3 mb-4 flex-row justify-between items-center">
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.uncollected")}
</Text>
<Text className="font-JakartaBold text-rose-500">
${(cashOwed / 100).toFixed(2)}
</Text>
</View>
) : null}
{/* Active ride */}
{dashboard?.active ? (
<ActiveRideCard
ride={dashboard.active}
busy={busy}
onArrived={(rideId) => void advance(rideId, "arrived")}
onStartTrip={() => {
setCodeError(null);
setCodeOpen(true);
}}
onComplete={completeRide}
onCancel={() => setCancelOpen(true)}
driverCoords={driverCoords}
/>
) : null}
{/* The board: open requests near this driver, newest and nearest
first. Hidden while they're on a ride — a driver mid-trip taking a
second job is the one thing this screen must not make easy. */}
<Text className="text-xl font-JakartaBold mt-4 mb-3 text-black dark:text-white">
{online
? t("driver.home.incomingRequests")
: t("driver.home.incomingRequestsOffline")}
</Text>
{!online || dashboard?.active ? null : dashboard?.requests.length ? (
dashboard.requests.map((request) => (
<RequestCard
key={request.ride_id}
request={request}
busy={busy}
clockOffset={clockOffset.current}
onOffer={() => respond(request, "offer")}
onWithdraw={() => respond(request, "withdraw")}
/>
))
) : (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-6 items-center">
<Image
source={images.noResult}
className="w-24 h-24"
resizeMode="contain"
/>
<Text className="text-general-200 dark:text-neutral-400 mt-2">
{!online
? t("driver.home.goOnlineStart")
: dashboard?.active
? t("driver.home.finishCurrentRide")
: t("driver.home.waitingRequests")}
</Text>
</View>
)}
</ScrollView>
{dashboard?.active ? (
<>
<PickupCodeSheet
visible={codeOpen}
submitting={busy}
error={codeError}
onCancel={() => setCodeOpen(false)}
onSubmit={(code) => void startTrip(dashboard.active!.ride_id, code)}
/>
<CancelSheet
visible={cancelOpen}
audience="driver"
submitting={busy}
onCancel={() => setCancelOpen(false)}
onConfirm={(reason) =>
void cancelRide(dashboard.active!.ride_id, reason)
}
/>
</>
) : null}
{/* Rate the rider once the trip is done. Prompted from the dashboard
rather than at drop-off so it survives the driver immediately
accepting their next request. */}
{pendingRating && !ratingSkipped.includes(pendingRating.ride_id) ? (
<RatingSheet
visible
rideId={pendingRating.ride_id}
audience="driver"
subjectName={pendingRating.rider_name}
onDone={() => {
setRatingSkipped((prev) => [...prev, pendingRating.ride_id]);
void fetchDashboard();
}}
onSkip={() =>
setRatingSkipped((prev) => [...prev, pendingRating.ride_id])
}
/>
) : null}
{/* Retaking the profile photo. The upload attaches itself server-side
for a driver who already has a profile, so all this has to do
afterwards is reload the profile and let the new photo show. */}
<ReactNativeModal
isVisible={photoOpen}
onBackdropPress={() => setPhotoOpen(false)}
>
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
<ProfilePhotoPicker
current={profile.profile_image_url}
onUploaded={() => void loadProfile()}
/>
<CustomButton
title={t("common.save")}
onPress={() => setPhotoOpen(false)}
/>
</View>
</ReactNativeModal>
</SafeAreaView>
);
};
// --- Credentials --------------------------------------------------------
// A labelled text input matching the onboarding form's styling. Pulled out
// because onboarding and the resubmit-after-rejection flow collect the same
// four fields and must not drift apart.
const Field = ({
label,
value,
onChange,
placeholder,
keyboardType,
autoCapitalize = "characters",
}: {
label: string;
value: string;
onChange: (next: string) => void;
placeholder: string;
keyboardType?: "default" | "number-pad" | "numbers-and-punctuation";
autoCapitalize?: "none" | "characters" | "words";
}) => {
const { isDark } = useTheme();
return (
<>
<Text className="text-sm font-JakartaSemiBold mb-2 text-black dark:text-white">
{label}
</Text>
<TextInput
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor={isDark ? "#737373" : "#858585"}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize}
autoCorrect={false}
className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-4"
/>
</>
);
};
const CredentialFields = ({
values,
onChange,
only,
}: {
values: Credentials;
onChange: (next: Credentials) => void;
/**
* Render just these fields. Used to ask for the one value a scan couldn't
* read without putting the three it did read back in front of the driver as
* a form to re-check.
*/
only?: readonly (keyof Credentials)[];
}) => {
const t = useT();
const set = (key: keyof Credentials) => (next: string) =>
onChange({ ...values, [key]: next });
const show = (key: keyof Credentials) => !only || only.includes(key);
return (
<>
{show("license_number") && (
<Field
label={t("driver.credentials.licenseNumber")}
value={values.license_number}
onChange={set("license_number")}
placeholder={t("driver.credentials.licenseNumberPlaceholder")}
/>
)}
{show("license_expiry") && (
<Field
label={t("driver.credentials.licenseExpiry")}
value={values.license_expiry}
onChange={set("license_expiry")}
placeholder="YYYY-MM-DD"
keyboardType="numbers-and-punctuation"
autoCapitalize="none"
/>
)}
{show("national_id") && (
<Field
label={t("driver.credentials.nationalId")}
value={values.national_id}
onChange={set("national_id")}
placeholder={t("driver.credentials.nationalIdPlaceholder")}
/>
)}
{show("plate_number") && (
<Field
label={t("driver.credentials.plateNumber")}
value={values.plate_number}
onChange={set("plate_number")}
placeholder={t("driver.credentials.plateNumberPlaceholder")}
/>
)}
</>
);
};
/**
* What the scans read, shown back as a receipt rather than a form.
*
* The driver is not asked to check these — a human reviewer does that against
* the stored scan before the profile is ever approved. This is here so the
* driver can see that something was actually read off their documents, and
* spot a wrong number if one happens to catch their eye.
*/
const CredentialSummary = ({ values }: { values: Credentials }) => {
const t = useT();
const rows: [string, string][] = [
[t("driver.credentials.licenseNumber"), values.license_number],
[t("driver.credentials.licenseExpiry"), values.license_expiry],
[t("driver.credentials.nationalId"), values.national_id],
[t("driver.credentials.plateNumber"), values.plate_number],
];
return (
<View className="bg-emerald-500/10 border border-emerald-500 rounded-2xl p-4 mb-4">
<View className="flex-row items-center mb-3">
<MaterialCommunityIcons
name="check-circle-outline"
size={16}
color="#10b981"
/>
<Text className="text-sm font-JakartaBold text-emerald-600 dark:text-emerald-400 ml-1.5">
{t("driver.scan.allRead")}
</Text>
</View>
{rows.map(([label, value]) => (
<View key={label} className="flex-row justify-between py-1">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 flex-1 pr-2">
{label}
</Text>
<Text className="text-xs font-JakartaBold text-black dark:text-white">
{value}
</Text>
</View>
))}
</View>
);
};
/**
* The three document scanners, and the credential fields they usually make
* unnecessary.
*
* The driver's job here is to photograph their documents — not to transcribe
* them and not to proof-read a form. So when a scan yields everything, no
* inputs are rendered at all: the values are shown back as a receipt and the
* driver submits. Typing only ever appears for what a scan genuinely could not
* read, which is the one case where hiding the field would leave the driver
* stuck with no way forward.
*
* What makes that safe is that nothing downstream trusts a scanned value:
* an owner reviews every profile against the stored image before it can take a
* ride, so a misread is caught by a person rather than by asking every driver
* to check every field on the off-chance.
*
* Onboarding and the resubmit-after-rejection flow both use this, which is
* what keeps the two paths from drifting — a rejected driver re-scans exactly
* the documents a new one scans.
*
* The merge rule is the other half. A scanned value is written into a field
* that is empty, or into one an earlier scan filled; a value the driver typed
* themselves is never overwritten. Without that, correcting a misread expiry
* and then re-scanning a blurry ID card would quietly stamp the correction
* back out — and the driver would submit a number they had already fixed once.
*/
const CredentialCapture = ({
values,
onChange,
documents,
onDocuments,
onFile,
prefilled,
onCarModel,
}: {
values: Credentials;
onChange: (next: Credentials) => void;
documents: DocumentRefs;
onDocuments: (next: DocumentRefs) => void;
/** Documents already stored on the profile, for the resubmission flow. */
onFile?: Partial<Record<DocumentType, boolean>>;
/**
* Fields whose starting value came from an earlier submission rather than
* from the driver typing it now. A resubmission is usually a rejection over
* exactly one of those values, so a rescan has to be allowed to replace
* them — otherwise re-photographing the licence leaves the wrong number the
* reviewer already rejected sitting in the form.
*/
prefilled?: readonly (keyof Credentials)[];
/** Called when a vehicle registration yields a car model. */
onCarModel?: (model: string) => void;
}) => {
const t = useT();
const [autofilled, setAutofilled] = useState<Set<keyof Credentials>>(
() => new Set(prefilled),
);
// A scan is on file already when resubmitting, so the summary and the
// "needed" set have to reflect the values that came back with the profile —
// not wait for a rescan that the driver may not have to make.
const hasScan =
documents.license_document !== null || Boolean(onFile?.license);
/**
* Fields a scan left empty, frozen at the moment the scan completed rather
* than derived from the current values. Deriving it live would make each
* input vanish the instant the driver typed the first character into it.
*/
const [needed, setNeeded] = useState<readonly (keyof Credentials)[]>(() =>
hasScan ? CREDENTIAL_KEYS.filter((key) => !values[key].trim()) : [],
);
// Opened by the driver when they spot a wrong value. Never the default:
// the point of scanning is that there is no form to work through.
const [editing, setEditing] = useState(false);
const applyScan = (
docType: DocumentType,
document: string,
fields: ScannedFields,
) => {
onDocuments({ ...documents, [DOCUMENT_KEY[docType]]: document });
const next = { ...values };
const filled = new Set(autofilled);
for (const field of CREDENTIAL_KEYS) {
const scanned = fields[field]?.trim();
if (!scanned) continue;
if (next[field].trim() && !filled.has(field)) continue;
next[field] = scanned;
filled.add(field);
}
setAutofilled(filled);
setNeeded(CREDENTIAL_KEYS.filter((key) => !next[key].trim()));
onChange(next);
if (fields.car_model) onCarModel?.(fields.car_model);
};
const scanners: { docType: DocumentType; optional?: boolean }[] = [
{ docType: "license" },
{ docType: "id", optional: true },
{ docType: "vehicle_reg", optional: true },
];
return (
<>
{scanners.map(({ docType, optional }) => (
<DocumentScanner
key={docType}
docType={docType}
label={t(`driver.scan.${docType}Label`)}
hint={t(`driver.scan.${docType}Hint`)}
optional={optional}
onFile={onFile?.[docType] ?? false}
onScanned={(document, fields) => applyScan(docType, document, fields)}
/>
))}
{/* Everything read: no form, just what we got. */}
{hasScan && needed.length === 0 && !editing ? (
<CredentialSummary values={values} />
) : null}
{/* Something didn't read, or the driver opened the details to fix a
value. Only the unreadable fields are asked for — the ones that
scanned cleanly stay out of the way unless editing is open. */}
{needed.length > 0 || editing ? (
<>
<Text className="text-sm text-general-200 dark:text-neutral-400 font-Jakarta mb-4">
{editing
? t("driver.scan.checkPrompt")
: t("driver.scan.missingPrompt")}
</Text>
<CredentialFields
values={values}
onChange={onChange}
only={editing ? undefined : needed}
/>
</>
) : null}
{/* An escape hatch for a driver who spots a wrong digit, deliberately
understated so it doesn't read as a step they have to complete. */}
{hasScan ? (
<TouchableOpacity
onPress={() => setEditing((open) => !open)}
className="self-start mb-4"
>
<Text className="text-sm font-JakartaBold text-primary-500">
{editing ? t("driver.scan.done") : t("driver.scan.edit")}
</Text>
</TouchableOpacity>
) : null}
</>
);
};
// Client-side mirror of the server's checks, so an obvious mistake is caught
// before a round trip. The server re-validates regardless.
const credentialError = (
values: Credentials,
hasLicenseScan: boolean,
): string | null => {
if (!hasLicenseScan) return "driver.credentials.errorScanRequired";
if (
!values.license_number.trim() ||
!values.national_id.trim() ||
!values.plate_number.trim()
) {
return "driver.credentials.errorMissing";
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(values.license_expiry.trim())) {
return "driver.credentials.errorExpiryFormat";
}
const expiry = new Date(`${values.license_expiry.trim()}T00:00:00Z`);
if (Number.isNaN(expiry.getTime()) || expiry.getTime() <= Date.now()) {
return "driver.credentials.errorExpired";
}
return null;
};
// --- Onboarding form ------------------------------------------------------
const Onboarding = ({
onCreated,
signOut,
userName,
}: {
onCreated: () => Promise<void>;
signOut: () => Promise<void>;
userName?: string | null;
}) => {
const t = useT();
const { isDark } = useTheme();
const [service, setService] = useState<ServiceId>("car");
const [carModel, setCarModel] = useState("");
const [carSeats, setCarSeats] = useState("4");
const [credentials, setCredentials] =
useState<Credentials>(EMPTY_CREDENTIALS);
const [documents, setDocuments] = useState<DocumentRefs>(EMPTY_DOCUMENTS);
const [photo, setPhoto] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const submit = async () => {
if (!photo) {
Alert.alert(
t("driver.credentials.errorTitle"),
t("driver.photo.required"),
);
return;
}
const seats = Number(carSeats);
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
Alert.alert(
t("driver.home.alertSeatsTitle"),
t("driver.home.alertSeatsBody"),
);
return;
}
const problem = credentialError(
credentials,
documents.license_document !== null,
);
if (problem) {
Alert.alert(t("driver.credentials.errorTitle"), t(problem));
return;
}
setSubmitting(true);
try {
await fetchAPI("/(api)/driver/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service,
car_model: carModel.trim() || null,
car_seats: seats,
license_number: credentials.license_number.trim(),
license_expiry: credentials.license_expiry.trim(),
national_id: credentials.national_id.trim(),
plate_number: credentials.plate_number.trim(),
profile_photo: photo,
...documents,
}),
});
await onCreated();
} catch (err) {
console.log("[DRIVER_ONBOARD]: ", err);
Alert.alert(
t("driver.home.alertErrorTitle"),
t("driver.home.alertCreateBody"),
);
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950">
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingBottom: 40 }}
>
<View className="flex-row items-center justify-between my-5">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{t("driver.home.welcome", {
name: userName?.split(" ")[0] || t("driver.home.welcomeFallback"),
})}
</Text>
<TouchableOpacity
onPress={signOut}
className="w-10 h-10 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center"
>
<Image
source={icons.out}
className="w-4 h-4"
alt={t("driver.home.signOutAlt")}
/>
</TouchableOpacity>
</View>
<Text className="text-base text-general-200 dark:text-neutral-400 font-Jakarta mb-4">
{t("driver.home.setupIntro")}
</Text>
{/* First thing in the form, because it is the first thing a rider
sees: the photo shown beside this driver's name when riders pick
between offers. */}
<ProfilePhotoPicker onUploaded={setPhoto} />
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("driver.home.whatDrive")}
</Text>
<View className="flex-row gap-2 mb-5">
{SERVICES.map((item) => {
const active = item.id === service;
return (
<TouchableOpacity
key={item.id}
onPress={() => setService(item.id)}
className={`flex-1 items-center rounded-2xl border py-3 ${
active
? "border-primary-500 bg-primary-500/10"
: "border-neutral-100 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900"
}`}
>
<MaterialCommunityIcons
name={item.icon}
size={24}
color={active ? "#0286ff" : isDark ? "#9ca3af" : "#858585"}
/>
<Text
className={`mt-1.5 text-xs font-JakartaBold ${
active ? "text-primary-500" : "text-black dark:text-white"
}`}
>
{t(item.labelKey)}
</Text>
</TouchableOpacity>
);
})}
</View>
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("driver.home.carModel")}
</Text>
<TextInput
value={carModel}
onChangeText={setCarModel}
placeholder={t("driver.home.carModelPlaceholder")}
placeholderTextColor={isDark ? "#737373" : "#858585"}
className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-4"
autoCapitalize="words"
/>
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("driver.home.carSeats")}
</Text>
<TextInput
value={carSeats}
onChangeText={setCarSeats}
placeholder={t("driver.home.carSeatsPlaceholder")}
placeholderTextColor={isDark ? "#737373" : "#858585"}
keyboardType="number-pad"
className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-8"
/>
<Text className="text-lg font-JakartaSemiBold mb-1 text-black dark:text-white">
{t("driver.credentials.title")}
</Text>
<Text className="text-sm text-general-200 dark:text-neutral-400 font-Jakarta mb-4">
{t("driver.credentials.intro")}
</Text>
<CredentialCapture
values={credentials}
onChange={setCredentials}
documents={documents}
onDocuments={setDocuments}
// The registration names the car, and the driver has almost
// certainly left the model blank at this point — but never overwrite
// something they typed themselves.
onCarModel={(model) => setCarModel((prev) => prev.trim() || model)}
/>
<Text className="text-xs text-general-200 dark:text-neutral-400 font-Jakarta mb-6 mt-2">
{t("driver.credentials.reviewNote")}
</Text>
<CustomButton
title={
submitting ? t("common.saving") : t("driver.credentials.submit")
}
onPress={submit}
disabled={submitting}
/>
</ScrollView>
</SafeAreaView>
);
};
// --- Review status --------------------------------------------------------
// What a driver sees between submitting their credentials and being cleared to
// drive. Pending and suspended are read-only; a rejection is actionable, so it
// carries the owner's reason and a form to correct and resubmit.
const REVIEW_POLL_MS = 20000;
const ReviewStatus = ({
profile,
onRefresh,
signOut,
}: {
profile: Profile;
onRefresh: () => Promise<void>;
signOut: () => Promise<void>;
}) => {
const t = useT();
const status = profile.approval_status;
const [credentials, setCredentials] = useState<Credentials>({
license_number: profile.license_number ?? "",
license_expiry: profile.license_expiry
? profile.license_expiry.slice(0, 10)
: "",
national_id: "",
plate_number: profile.plate_number ?? "",
});
const [documents, setDocuments] = useState<DocumentRefs>(EMPTY_DOCUMENTS);
const [submitting, setSubmitting] = useState(false);
// A decision can land at any moment and there's nothing else on this screen
// to do, so poll rather than making the driver pull to refresh.
useEffect(() => {
const timer = setInterval(() => void onRefresh(), REVIEW_POLL_MS);
return () => clearInterval(timer);
}, [onRefresh]);
const resubmit = async () => {
// A rejection is often about the numbers, not the scan, so a driver may
// resubmit on the licence photo already on file rather than re-taking it.
const hasLicenseScan =
documents.license_document !== null || profile.license_image_url !== null;
const problem = credentialError(credentials, hasLicenseScan);
if (problem) {
Alert.alert(t("driver.credentials.errorTitle"), t(problem));
return;
}
setSubmitting(true);
try {
await fetchAPI("/(api)/driver/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
license_number: credentials.license_number.trim(),
license_expiry: credentials.license_expiry.trim(),
national_id: credentials.national_id.trim(),
plate_number: credentials.plate_number.trim(),
...documents,
}),
});
await onRefresh();
} catch (err) {
console.log("[DRIVER_RESUBMIT]: ", err);
Alert.alert(
t("driver.home.alertErrorTitle"),
t("driver.credentials.alertResubmitBody"),
);
} finally {
setSubmitting(false);
}
};
const tone =
status === "rejected" || status === "suspended"
? {
badge: "bg-rose-500/10 border-rose-500",
text: "text-rose-500",
icon: "alert-circle-outline" as const,
}
: {
badge: "bg-amber-500/10 border-amber-500",
text: "text-amber-600 dark:text-amber-400",
icon: "clock-outline" as const,
};
return (
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingBottom: 40 }}
>
<View className="flex-row items-center justify-between my-5">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{t("driver.home.driverMode")}
</Text>
<TouchableOpacity
onPress={signOut}
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center"
>
<Image
source={icons.out}
className="w-4 h-4"
alt={t("driver.home.signOutAlt")}
/>
</TouchableOpacity>
</View>
<View className={`rounded-2xl border p-6 items-center ${tone.badge}`}>
<MaterialCommunityIcons
name={tone.icon}
size={40}
color={status === "pending" ? "#d97706" : "#f43f5e"}
/>
<Text
className={`text-lg font-JakartaBold mt-3 text-center ${tone.text}`}
>
{t(`driver.review.${status}Title`)}
</Text>
<Text className="text-sm font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-2">
{t(`driver.review.${status}Body`)}
</Text>
</View>
{status === "rejected" && profile.rejection_reason ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4">
<Text className="text-xs font-JakartaSemiBold uppercase text-general-200 dark:text-neutral-500 mb-1">
{t("driver.review.reasonLabel")}
</Text>
<Text className="font-Jakarta text-black dark:text-white">
{profile.rejection_reason}
</Text>
</View>
) : null}
{status === "pending" ? (
<TouchableOpacity
onPress={() => void onRefresh()}
className="rounded-full py-3 mt-4 items-center border border-neutral-300 dark:border-neutral-800"
>
<Text className="font-JakartaBold text-black dark:text-white">
{t("driver.review.checkAgain")}
</Text>
</TouchableOpacity>
) : null}
{status === "rejected" ? (
<View className="mt-6">
<Text className="text-lg font-JakartaSemiBold mb-1 text-black dark:text-white">
{t("driver.review.resubmitTitle")}
</Text>
<Text className="text-sm text-general-200 dark:text-neutral-400 font-Jakarta mb-4">
{t("driver.review.resubmitIntro")}
</Text>
<CredentialCapture
values={credentials}
onChange={setCredentials}
documents={documents}
onDocuments={setDocuments}
onFile={{
license: profile.license_image_url !== null,
id: profile.id_image_url !== null,
vehicle_reg: profile.vehicle_reg_image_url !== null,
}}
// Everything seeded from the rejected profile. national_id is
// absent because it is deliberately not sent back to the client,
// so the field starts empty and a scan may fill it freely.
prefilled={PREFILLED_ON_RESUBMIT}
/>
<CustomButton
title={
submitting ? t("common.saving") : t("driver.review.resubmit")
}
onPress={resubmit}
disabled={submitting}
className="mt-2"
/>
</View>
) : null}
</ScrollView>
</SafeAreaView>
);
};
// --- Request card ---------------------------------------------------------
const RequestCard = ({
request,
busy,
clockOffset,
onOffer,
onWithdraw,
}: {
request: OpenRequest;
busy: boolean;
clockOffset: number;
onOffer: () => void;
onWithdraw: () => void;
}) => {
const t = useT();
// Redraw once a second so the countdown actually counts. The dashboard poll
// is every 4s, which is too coarse for a clock the driver is reading.
const [, setTick] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(timer);
}, []);
// How long the job stays on the board. Under the broadcast model this is
// the request's own life, not a per-driver deadline: nothing passes to
// anybody when it runs out, the request simply dies unpicked.
const elapsed =
(Date.now() + clockOffset - Date.parse(request.created_at)) / 1000;
const remaining = Math.max(0, Math.ceil(REQUEST_TTL_SECONDS - elapsed));
const fraction = Math.max(0, Math.min(1, remaining / REQUEST_TTL_SECONDS));
const urgent = remaining <= 20;
const offered = request.my_offer_id !== null;
const km = Math.round(request.pickup_distance_m / 100) / 10;
// Somebody else wanting the job is information the driver is entitled to:
// it's the difference between "I'll think about it" and "offer now".
const rivals = Math.max(0, request.offer_count - (offered ? 1 : 0));
return (
<View
className={`bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-3 ${
offered ? "border border-emerald-500" : ""
}`}
>
<View className="flex-row items-center justify-between mb-2">
<Text className="text-sm font-JakartaBold text-primary-500">
{t("driver.offerCard.newRequest", { service: request.service })}
</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400">
{rivals > 0
? t("driver.offerCard.rivals", undefined, rivals)
: t("driver.offerCard.firstIn")}
</Text>
</View>
{/* How long the job is on the board for. */}
<View className="mb-3">
<View className="flex-row items-center justify-between mb-1">
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("driver.offerCard.openFor")}
</Text>
<Text
className={`text-xs font-JakartaBold ${
urgent ? "text-rose-500" : "text-black dark:text-white"
}`}
>
{t("driver.offerCard.seconds", { n: String(remaining) })}
</Text>
</View>
<View className="h-1.5 rounded-full bg-neutral-200 dark:bg-neutral-800 overflow-hidden">
<View
className={`h-full rounded-full ${
urgent ? "bg-rose-500" : "bg-emerald-500"
}`}
style={{ width: `${fraction * 100}%` }}
/>
</View>
</View>
{/* Distance to the pickup: the single most useful thing to know before
putting your name on a job. Computed server-side from the same
position dispatch matched on, so it agrees with what got you here. */}
<View className="flex-row items-center justify-between mb-2">
<View className="flex-row items-center gap-x-2">
<MaterialCommunityIcons
name="map-marker-distance"
size={16}
color="#0286ff"
/>
<Text className="text-xs font-JakartaBold text-primary-500">
{t("driver.offerCard.awayFromPickup", { km })}
</Text>
</View>
{request.rider_name ? (
<View className="flex-row items-center gap-x-1">
<Text className="text-xs text-general-200 dark:text-neutral-400">
{request.rider_name}
</Text>
{request.rider_rating ? (
<Text className="text-xs text-general-200 dark:text-neutral-400">
{Number(request.rider_rating).toFixed(1)}
</Text>
) : null}
</View>
) : null}
</View>
<View className="flex-row items-center gap-x-2 mb-1">
<Image
source={icons.to}
alt={t("driver.offerCard.fromAlt")}
className="w-4 h-4"
/>
<Text
className="font-JakartaMedium text-black dark:text-white"
numberOfLines={1}
>
{request.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mb-3">
<Image
source={icons.point}
alt={t("driver.offerCard.toAlt")}
className="w-4 h-4"
/>
<Text
className="font-JakartaMedium text-black dark:text-white"
numberOfLines={1}
>
{request.destination_address}
</Text>
</View>
<View className="flex-row justify-between mb-3">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("driver.offerCard.tripTime")}
</Text>
<Text className="font-JakartaMedium text-xs text-black dark:text-white">
{formatTime(request.ride_time)}
</Text>
</View>
<View className="flex-row justify-between mb-3">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("driver.offerCard.youEarn")}
</Text>
<Text className="font-JakartaBold text-sm text-emerald-600 dark:text-emerald-400">
${(request.payout_cents / 100).toFixed(2)}
</Text>
</View>
{/* Offered already: the wait is on the rider, and the only move left is
to take it back. Said plainly, because an offer that looks like a
booking is how a driver ends up parked outside a pickup that was
never theirs. */}
{offered ? (
<>
<View className="flex-row items-center justify-center gap-x-2 mb-2">
<ActivityIndicator size="small" color="#10b981" />
<Text className="text-xs font-JakartaBold text-emerald-600 dark:text-emerald-400">
{t("driver.offerCard.waitingOnRider")}
</Text>
</View>
<TouchableOpacity
onPress={onWithdraw}
disabled={busy}
className="rounded-full py-3 items-center border border-neutral-300 dark:border-neutral-700"
>
<Text className="font-JakartaBold text-neutral-700 dark:text-neutral-200">
{t("driver.offerCard.withdraw")}
</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity
onPress={onOffer}
disabled={busy}
className="rounded-full py-3 bg-emerald-500 items-center"
>
<Text className="font-JakartaBold text-white">
{busy ? "…" : t("driver.offerCard.offer")}
</Text>
</TouchableOpacity>
)}
</View>
);
};
// --- Active ride card -----------------------------------------------------
const ActiveRideCard = ({
ride,
busy,
onArrived,
onStartTrip,
onComplete,
onCancel,
driverCoords,
}: {
ride: ActiveRide;
busy: boolean;
onArrived: (rideId: number) => void;
onStartTrip: () => void;
onComplete: (ride: ActiveRide) => void;
onCancel: () => void;
driverCoords: { latitude: number; longitude: number } | null;
}) => {
const t = useT();
const statusLabel =
ride.status === "accepted"
? t("driver.activeRide.headToPickup")
: ride.status === "arrived"
? t("driver.activeRide.atPickup")
: ride.status === "en_route"
? t("driver.activeRide.tripInProgress")
: ride.status;
// Before the rider is aboard the driver is heading to the pickup; after,
// to the drop-off. Both the map and the navigation handoff follow this.
const heading = ride.status === "en_route" ? "dropoff" : "pickup";
const openNavigation = () => {
const lat =
heading === "pickup" ? ride.origin_latitude : ride.destination_latitude;
const lng =
heading === "pickup" ? ride.origin_longitude : ride.destination_longitude;
// The universal Maps URL opens the native Google Maps app when it's
// installed and falls back to the browser when it isn't, on both
// platforms — no per-platform scheme juggling and no extra dependency.
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&travelmode=driving`;
Linking.openURL(url).catch((err) => {
console.log("[DRIVER_NAVIGATE]: ", err);
Alert.alert(
t("driver.activeRide.alertErrorTitle"),
t("driver.activeRide.alertNavigateBody"),
);
});
};
return (
<View className="bg-primary-500/10 border border-primary-500 rounded-2xl p-4 mb-4">
<View className="flex-row items-center justify-between mb-2">
<Text className="text-sm font-JakartaBold text-primary-500">
{statusLabel}
</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400">
{ride.service}
</Text>
</View>
{ride.rider_name ? (
<View className="flex-row items-center justify-between mb-2">
<View className="flex-row items-center">
<Text className="font-JakartaBold text-black dark:text-white">
{t("driver.activeRide.rider", { name: ride.rider_name })}
</Text>
{/* Riders are rated too — a driver should know who they're
picking up before they pull over for them. */}
{ride.rider_rating ? (
<Text className="ml-2 text-xs text-general-200 dark:text-neutral-400">
{Number(ride.rider_rating).toFixed(1)}
</Text>
) : null}
</View>
<View className="flex-row gap-2">
<TouchableOpacity
onPress={() => router.push("/(root)/driver-chat")}
accessibilityLabel={t("driver.activeRide.message")}
className="w-9 h-9 rounded-full bg-primary-500 items-center justify-center"
>
<MaterialCommunityIcons
name="message-text"
size={18}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() =>
router.push({
pathname: "/(root)/call",
params: {
rideId: String(ride.ride_id),
role: "driver",
mode: "start",
},
})
}
accessibilityLabel={t("driver.activeRide.call")}
className="w-9 h-9 rounded-full bg-emerald-500 items-center justify-center"
>
<MaterialCommunityIcons name="phone" size={18} color="white" />
</TouchableOpacity>
</View>
</View>
) : null}
{/* The map stays up for the whole ride. It used to disappear the moment
the trip started — exactly when the driver needs the route most —
leaving them with a destination address as plain text. Before pickup
it points at the rider; once they're aboard, at the drop-off. */}
<View className="h-40 rounded-xl overflow-hidden mb-3">
<Map
originOverride={driverCoords}
destinationOverride={
heading === "pickup"
? {
latitude: ride.origin_latitude,
longitude: ride.origin_longitude,
label: t("driver.activeRide.pickupPin"),
}
: {
latitude: ride.destination_latitude,
longitude: ride.destination_longitude,
label: t("driver.activeRide.dropoffPin"),
}
}
/>
</View>
{/* Hand off to whatever navigation app the driver actually uses. The
in-app map shows the shape of the trip; it is not turn-by-turn. */}
<TouchableOpacity
onPress={openNavigation}
className="flex-row items-center justify-center gap-x-2 rounded-full py-3 mb-3 bg-primary-500"
>
<MaterialCommunityIcons
name="navigation-variant"
size={18}
color="white"
/>
<Text className="font-JakartaBold text-white">
{heading === "pickup"
? t("driver.activeRide.navigateToPickup")
: t("driver.activeRide.navigateToDropoff")}
</Text>
</TouchableOpacity>
<View className="flex-row items-center gap-x-2 mb-1">
<Image
source={icons.to}
alt={t("driver.activeRide.fromAlt")}
className="w-4 h-4"
/>
<Text
className="font-JakartaMedium text-black dark:text-white"
numberOfLines={1}
>
{ride.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mb-3">
<Image
source={icons.point}
alt={t("driver.activeRide.toAlt")}
className="w-4 h-4"
/>
<Text
className="font-JakartaMedium text-black dark:text-white"
numberOfLines={1}
>
{ride.destination_address}
</Text>
</View>
<View className="flex-row justify-between mb-4">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("driver.activeRide.youEarn")}
</Text>
<Text className="font-JakartaBold text-sm text-emerald-600 dark:text-emerald-400">
${(ride.payout_cents / 100).toFixed(2)}
</Text>
</View>
{/* Heading to the pickup: the only forward action is "I'm here". */}
{ride.status === "accepted" ? (
<>
<CustomButton
title={busy ? "…" : t("driver.activeRide.imHere")}
bgVariant="success"
onPress={() => onArrived(ride.ride_id)}
className="mb-2"
/>
<TouchableOpacity
onPress={onCancel}
disabled={busy}
className="rounded-full py-3 items-center border border-rose-300 dark:border-rose-900"
>
<Text className="font-JakartaBold text-rose-500">
{t("driver.activeRide.cancelRide")}
</Text>
</TouchableOpacity>
</>
) : null}
{/* At the pickup, waiting on the rider and their code. */}
{ride.status === "arrived" ? (
<>
<Text className="text-xs text-general-200 dark:text-neutral-400 mb-2 text-center">
{t("driver.activeRide.askForCode")}
</Text>
<CustomButton
title={busy ? "…" : t("driver.activeRide.startTrip")}
bgVariant="success"
onPress={onStartTrip}
className="mb-2"
/>
<TouchableOpacity
onPress={onCancel}
disabled={busy}
className="rounded-full py-3 items-center border border-rose-300 dark:border-rose-900"
>
<Text className="font-JakartaBold text-rose-500">
{t("driver.activeRide.cancelRide")}
</Text>
</TouchableOpacity>
</>
) : null}
{ride.status === "en_route" ? (
<CustomButton
title={busy ? "…" : t("driver.activeRide.completeTrip")}
bgVariant="success"
onPress={() => onComplete(ride)}
/>
) : null}
</View>
);
};
export default DriverHome;