import * as Location from "expo-location"; import { useCallback, useEffect, useState } from "react"; import { useLocationStore } from "@/store"; export type LocationStatus = /** Still asking for permission or waiting on the first fix. */ | "pending" /** Coordinates are in the store. */ | "granted" /** The user said no to the permission prompt. */ | "denied" /** Permission is fine, but location is switched off device-wide. */ | "services-off" /** Permission is fine and services are on, but no fix arrived. */ | "unavailable"; // getCurrentPositionAsync has no timeout of its own: indoors, or on an // emulator with no mock location set, it waits for a GPS fix that never // arrives and the screen sits on a spinner forever. This is the cap. const FIX_TIMEOUT_MS = 15_000; const LAST_KNOWN_MAX_AGE_MS = 5 * 60 * 1000; const withTimeout = (promise: Promise, ms: number): Promise => Promise.race([ promise, new Promise((resolve) => { setTimeout(() => resolve(null), ms); }), ]); /** * Resolves the rider's position into the location store. * * Takes the fastest usable fix rather than the best one: a cached position * renders the map immediately, and a precise reading replaces it when (or if) * it arrives. Reverse geocoding is fired separately so a failed lookup costs * the address label, never the coordinates. */ export const useUserLocation = () => { const setUserLocation = useLocationStore((state) => state.setUserLocation); const [status, setStatus] = useState("pending"); const [attempt, setAttempt] = useState(0); const retry = useCallback(() => setAttempt((count) => count + 1), []); useEffect(() => { let cancelled = false; const apply = ({ coords }: Location.LocationObject) => { const { latitude, longitude } = coords; setUserLocation({ latitude, longitude, address: "Your location" }); Location.reverseGeocodeAsync({ latitude, longitude }) .then(([place]) => { if (cancelled || !place) return; const address = [place.name, place.city ?? place.region] .filter(Boolean) .join(", "); if (address) setUserLocation({ latitude, longitude, address }); }) .catch((error) => console.log("[REVERSE_GEOCODE]: ", error)); }; const resolve = async () => { setStatus("pending"); try { const { status: permission } = await Location.requestForegroundPermissionsAsync(); if (cancelled) return; if (permission !== "granted") { setStatus("denied"); return; } // Granting the app permission doesn't help if the device radio is off, // and getCurrentPositionAsync throws rather than saying so clearly. if (!(await Location.hasServicesEnabledAsync())) { if (!cancelled) setStatus("services-off"); return; } const cached = await Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS, }); if (cancelled) return; if (cached) { apply(cached); setStatus("granted"); } const fresh = await withTimeout( Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced, }), FIX_TIMEOUT_MS, ); if (cancelled) return; if (fresh) { apply(fresh); setStatus("granted"); } else if (!cached) { setStatus("unavailable"); } } catch (error) { console.log("[LOCATION]: ", error); // A cached fix already on screen is better than an error panel. if (!cancelled) { setStatus((current) => current === "granted" ? current : "unavailable", ); } } }; void resolve(); return () => { cancelled = true; }; }, [attempt, setUserLocation]); return { status, retry }; };