Fix home map and location, add service selector

The Android map never drew because mapType was "mutedStandard", an Apple
Maps value. Android's MapManager looks the name up in a fixed table and
unboxes the result into an int, so an unrecognised value threw a
NullPointerException in the native view manager before any tile
rendered. Android now gets "standard" plus a customMapStyle that mutes
POI and transit labels, since showsPointsOfInterest is iOS-only too.

Location hung indefinitely: getCurrentPositionAsync was called with no
accuracy and no timeout, so it waited for a GPS fix that never arrives
indoors or on an emulator with no mock location. useUserLocation now
takes a cached fix first for an immediate render, caps the precise
reading at 15 seconds, and checks device location services separately
from app permission. Reverse geocoding moved off the critical path so a
failed lookup costs the address label rather than the coordinates. The
single boolean became five states, each with its own notice and either a
retry or a settings shortcut, since retrying a hard denial does nothing.

Map also no longer deletes itself when the driver fetch fails or the
location is still pending: drivers are an overlay, and calculateRegion
already falls back to Beirut.

Adds a four-tile service selector above Recent Rides - Car, Moto,
Courier, My Car - with the choice held in useServiceStore for the
booking flow to read. English only for now; the intended Arabic names
are recorded in constants/services.ts for the language pass. Selection
styling matches the role picker on sign-up so the two read as one
control.

app.config.js layers the Google Maps key and expo-location permission
strings onto app.json. No effect under Expo Go, which ignores native
config, but required for the first EAS build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krikorios
2026-08-23 23:34:04 +03:00
co-authored by Claude Opus 5
parent bc23c94ea2
commit 59e336c23d
8 changed files with 471 additions and 95 deletions
+136
View File
@@ -0,0 +1,136 @@
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 = <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: "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 };
};