142 lines
4.0 KiB
TypeScript
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 };
|
|
};
|