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' }, });