import LottieView from 'lottie-react-native';
import { useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { DriverNavigation } from '@/components/DriverNavigation';
import { RideMap } from '@/components/RideMap';
import { useDispatch } from '@/lib/dispatch/DispatchContext';
import { device, startsDriving } from '@/lib/dispatch/device';
import type { DriverJobView, DriverOfferView, DriverResult } from '@/lib/dispatch/protocol';
import { useSecondsLeft } from '@/lib/dispatch/useSecondsLeft';
import { pickupRoute } from '@/lib/navigation';
import { useRide } from '@/lib/RideContext';
import { formatDistance, formatDuration, formatPrice, getRideType } from '@/lib/rideTypes';
export default function DriverScreen() {
const insets = useSafeAreaInsets();
const { connected, snapshot, error, send } = useDispatch();
const { pickup: position } = useRide();
const [driving, setDriving] = useState(startsDriving);
// Dispatch only sends requests to drivers in driving mode. Tell it on every (re)connect.
useEffect(() => {
if (connected) send({ type: 'setDriving', driving });
}, [connected, driving, send]);
// Where the driver is counts toward supply in that surge zone.
useEffect(() => {
if (connected) send({ type: 'setPosition', position });
}, [connected, position, send]);
const driver = snapshot?.role === 'driver' ? snapshot : null;
const job = driver?.job ?? null;
const nextJob = driver?.nextJob ?? null;
const offers = driver?.offers ?? [];
const openOffers = offers.filter((offer) => !offer.declined);
const passedOffers = offers.filter((offer) => offer.declined);
return (
{/* On a ride: directions, which stay mounted under ride requests. Otherwise the map. */}
{job ? (
) : (
)}
{!job && (driving ? : setDriving(true)} />)}
{job && send({ type: 'completeTrip', rideId: job.rideId })} />}
{nextJob && (
Next: {nextJob.riderName} → {nextJob.destination.title} · you earn {formatPrice(nextJob.earnings)}
)}
{!job &&
passedOffers.map((offer) => (
You passed at {formatPrice(offer.price)} · {offer.riderName} → {offer.destination.title}
))}
{!job && driver?.lastResult && }
{error && (
{error}
)}
{device.name}
{!connected ? 'Connecting…' : driving ? 'Driving' : 'Offline'}
{/* While a request is up, its sheet carries this button instead. */}
{driving ? (
openOffers.length === 0 && setDriving(false)} />
) : (
job && (
setDriving(true)} accessibilityRole="button" testID="start-driving">
Start driving
)
)}
{openOffers.length > 0 && (
{openOffers.length === 1 ? 'New ride request' : `${openOffers.length} ride requests`}
setDriving(false)} />
{job && Accepting makes this your next ride, after {job.riderName}.}
{openOffers.map((offer) => (
send({ type: 'accept', rideId: offer.rideId, round: offer.round })}
onPass={() => send({ type: 'decline', rideId: offer.rideId, round: offer.round })}
/>
))}
)}
);
}
function GoOfflineButton({ onPress }: { onPress: () => void }) {
return (
Go offline
);
}
/** Driving with no ride: waiting for requests. */
function Waiting() {
return (
Looking for ride requests
Requests pop up here while you’re driving.
);
}
function Offline({ onStart }: { onStart: () => void }) {
return (
You’re offline
Start driving to get ride requests.
Start driving
);
}
function Offer({ offer, onAccept, onPass }: { offer: DriverOfferView; onAccept: () => void; onPass: () => void }) {
const secondsLeft = useSecondsLeft(offer.closesAt);
return (
Ride request · round {offer.round}
{secondsLeft}s left
{offer.riderName} → {offer.destination.title}
{getRideType(offer.rideTypeId).name} · {formatDistance(offer.distanceMeters)} · {formatDuration(offer.durationSeconds)}
{formatPrice(offer.earnings)}
You earn · rider pays {formatPrice(offer.price)}
Pass
Accept
);
}
function Job({ job, onComplete }: { job: DriverJobView; onComplete: () => void }) {
return (
You got the ride
{job.riderName} → {job.destination.title}
You earn {formatPrice(job.earnings)}
Fare {formatPrice(job.fare)}
Complete trip
);
}
const RESULT_TEXT: Record = {
lost: 'Another driver took that ride',
expired: 'That request closed before you answered',
cancelled: 'The rider cancelled their request',
completed: 'Trip complete',
};
function ResultBanner({ result }: { result: DriverResult }) {
return (
{RESULT_TEXT[result.outcome]}
{result.outcome === 'completed' ? ` · you earned ${formatPrice(result.earnings)}` : ''}
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#e8ecef' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 16,
paddingTop: 16,
gap: 10,
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 12,
},
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
dot: { width: 10, height: 10, borderRadius: 5 },
name: { fontSize: 17, fontWeight: '700', color: '#111', flex: 1 },
muted: { color: '#666', fontSize: 14 },
error: { backgroundColor: '#fdecec', color: '#9b1c1c', padding: 10, borderRadius: 8, overflow: 'hidden' },
banner: { backgroundColor: '#f1f1f1', color: '#333', padding: 12, borderRadius: 8, overflow: 'hidden' },
next: { backgroundColor: '#eef7f1', color: '#0b6e4f', padding: 10, borderRadius: 8, overflow: 'hidden', fontSize: 14, fontWeight: '600' },
center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 160, alignItems: 'center', justifyContent: 'center', gap: 6, paddingHorizontal: 24 },
animation: { width: 220, height: 220 },
centerTitle: { fontSize: 20, fontWeight: '700', color: '#111' },
offlineCard: { backgroundColor: '#fff', borderRadius: 16, padding: 20, gap: 6, alignItems: 'center', alignSelf: 'stretch' },
startButton: { flex: 0, alignSelf: 'stretch', marginTop: 10 },
smallButton: { backgroundColor: '#eee', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 6 },
smallButtonText: { fontSize: 14, fontWeight: '600', color: '#111' },
passed: { backgroundColor: '#eef4fa', color: '#1a5f8f', padding: 10, borderRadius: 8, overflow: 'hidden', fontSize: 14 },
overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.35)', justifyContent: 'flex-end' },
offerSheet: { flexGrow: 0, maxHeight: '75%', backgroundColor: '#fff', borderTopLeftRadius: 20, borderTopRightRadius: 20 },
offerSheetContent: { padding: 16, gap: 12 },
sheetHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
sheetTitle: { fontSize: 20, fontWeight: '700', color: '#111' },
card: { backgroundColor: '#f6f6f6', borderRadius: 14, padding: 16, gap: 8 },
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
cardTitle: { fontSize: 13, fontWeight: '600', color: '#666', textTransform: 'uppercase' },
destination: { fontSize: 18, fontWeight: '600', color: '#111' },
price: { fontSize: 32, fontWeight: '700', color: '#111' },
fare: { fontSize: 16, color: '#111' },
won: { fontSize: 13, fontWeight: '700', color: '#1a8f3c', textTransform: 'uppercase' },
buttons: { flexDirection: 'row', gap: 8 },
button: { flex: 1, borderRadius: 10, paddingVertical: 14, alignItems: 'center' },
primary: { backgroundColor: '#111' },
primaryText: { color: '#fff', fontSize: 16, fontWeight: '600' },
secondary: { backgroundColor: '#e4e4e4' },
secondaryText: { color: '#111', fontSize: 16, fontWeight: '600' },
});