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>
1986 lines
69 KiB
TypeScript
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;
|