import * as Location from 'expo-location';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { simulatedLocation } from './dispatch/device';
import { useDispatch } from './dispatch/DispatchContext';
import type { Quote, RiderRideView } from './dispatch/protocol';
import { DEFAULT_LOCATION } from './location';
import { RIDE_TYPES } from './rideTypes';
import { fetchRoute } from './route';
import { saveTrip } from './storage';
import type { Coord, Place, Route, Trip } from './types';
type LocationStatus = 'pending' | 'granted' | 'denied';
type RideState = {
userLocation: Coord | null;
pickup: Coord;
locationStatus: LocationStatus;
destination: Place | null;
route: Route | null;
routeLoading: boolean;
rideTypeId: string;
quote: Quote | null;
ride: RiderRideView | null;
requesting: boolean;
dispatchError: string | null;
setDestination: (place: Place | null) => void;
setRideTypeId: (id: string) => void;
requestRide: () => boolean;
cancelRide: () => void;
dismissRide: () => void;
completeTrip: () => Promise<void>;
};
const RideContext = createContext<RideState | null>(null);
export function RideProvider({ children }: { children: ReactNode }) {
const [userLocation, setUserLocation] = useState<Coord | null>(simulatedLocation);
const [locationStatus, setLocationStatus] = useState<LocationStatus>(simulatedLocation ? 'granted' : 'pending');
const [destination, setDestination] = useState<Place | null>(null);
const [route, setRoute] = useState<Route | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
const [rideTypeId, setRideTypeId] = useState(RIDE_TYPES[0].id);
const [requestPending, setRequestPending] = useState(false);
const { connected, snapshot, error: dispatchError, send } = useDispatch();
const ride = snapshot?.role === 'rider' ? snapshot.ride : null;
const requesting = requestPending && !ride && !dispatchError;
const latestQuote = snapshot?.role === 'rider' ? snapshot.quote : null;
const quote = latestQuote && latestQuote.destinationId === destination?.id ? latestQuote : null;
const pickup = userLocation ?? DEFAULT_LOCATION;
const routeRequest = useRef<AbortController | null>(null);
useEffect(() => {
if (simulatedLocation) return;
let subscription: Location.LocationSubscription | undefined;
let cancelled = false;
(async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (cancelled) return;
if (status !== 'granted') {
setLocationStatus('denied');
return;
}
setLocationStatus('granted');
subscription = await Location.watchPositionAsync(
{ accuracy: Location.Accuracy.Balanced, distanceInterval: 25 },
({ coords }) => setUserLocation({ latitude: coords.latitude, longitude: coords.longitude }),
);
if (cancelled) subscription.remove();
})().catch(() => {
if (!cancelled) setLocationStatus('denied');
});
return () => {
cancelled = true;
subscription?.remove();
};
}, []);
const chooseDestination = useCallback(
(place: Place | null) => {
routeRequest.current?.abort();
setDestination(place);
setRoute(null);
if (!place) {
setRouteLoading(false);
return;
}
const controller = new AbortController();
routeRequest.current = controller;
setRouteLoading(true);
fetchRoute(pickup, place.coordinate, controller.signal)
.then(setRoute)
.catch(() => {})
.finally(() => {
if (!controller.signal.aborted) setRouteLoading(false);
});
},
[pickup],
);
const needsQuote = connected && destination !== null && route !== null && !ride && !requestPending;
const quotedRoute = useRef<Route | null>(null);
const quoteExpiresAt = quote?.expiresAt;
useEffect(() => {
if (!needsQuote || !destination || !route) return;
const requestQuote = () =>
send({
type: 'requestQuote',
request: { pickup, destination, distanceMeters: route.distanceMeters, durationSeconds: route.durationSeconds },
});
if (quotedRoute.current !== route || quoteExpiresAt === undefined) {
quotedRoute.current = route;
requestQuote();
return;
}
const timer = setTimeout(requestQuote, Math.max(0, quoteExpiresAt - Date.now()));
return () => clearTimeout(timer);
}, [needsQuote, destination, route, quoteExpiresAt, send]);
const requestRide = useCallback(() => {
if (!quote) return false;
const sent = send({ type: 'requestRide', quoteId: quote.id, rideTypeId });
setRequestPending(sent);
return sent;
}, [quote, rideTypeId, send]);
const cancelRide = useCallback(() => {
send({ type: 'cancelRide' });
setRequestPending(false);
}, [send]);
const dismissRide = useCallback(() => {
send({ type: 'ackRide' });
setRequestPending(false);
}, [send]);
const completeTrip = useCallback(async () => {
const trip = ride && tripFromRide(ride);
if (trip) await saveTrip(trip);
dismissRide();
chooseDestination(null);
}, [ride, dismissRide, chooseDestination]);
const value = useMemo<RideState>(
() => ({
userLocation,
pickup,
locationStatus,
destination,
route,
routeLoading,
rideTypeId,
quote,
ride,
requesting,
dispatchError,
setDestination: chooseDestination,
setRideTypeId,
requestRide,
cancelRide,
dismissRide,
completeTrip,
}),
[
userLocation,
pickup,
locationStatus,
destination,
route,
routeLoading,
rideTypeId,
quote,
ride,
requesting,
dispatchError,
chooseDestination,
requestRide,
cancelRide,
dismissRide,
completeTrip,
],
);
return <RideContext.Provider value={value}>{children}</RideContext.Provider>;
}
export function tripFromRide(ride: RiderRideView): Trip | null {
if (!ride.driver || ride.fare === null) return null;
return {
id: ride.id,
destination: ride.destination,
rideTypeId: ride.rideTypeId,
fare: ride.fare,
distanceMeters: ride.distanceMeters,
durationSeconds: ride.durationSeconds,
driver: ride.driver,
completedAt: ride.completedAt ?? new Date().toISOString(),
};
}
export function useRide(): RideState {
const ctx = useContext(RideContext);
if (!ctx) throw new Error('useRide must be used inside <RideProvider>');
return ctx;
}