Files
waseel/lib/use-user-location.ts

142 lines
4.0 KiB
TypeScript

import * as Location from "expo-location";
import { useCallback, useEffect, useState } from "react";
import { tr } from "@/lib/i18n";
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 = <T>(promise: Promise<T>, ms: number): Promise<T | null> =>
Promise.race([
promise,
new Promise<null>((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<LocationStatus>("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: tr("common.yourLocation"),
});
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 };
};