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
+51
View File
@@ -0,0 +1,51 @@
// Dynamic config layered over app.json.
//
// react-native-maps reads the Google Maps key from the *native* manifest
// (AndroidManifest `com.google.android.geo.API_KEY`), not from the JS bundle,
// so EXPO_PUBLIC_GOOGLE_API_KEY has to be injected here at build time. Without
// it Android renders an empty grey tile area instead of a map.
const googleMapsApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
const LOCATION_PERMISSION =
"Waseel uses your location to show nearby drivers and set your pickup point.";
module.exports = ({ config }) => {
if (!googleMapsApiKey) {
console.warn(
"[app.config] EXPO_PUBLIC_GOOGLE_API_KEY is not set — maps will render blank on Android.",
);
}
return {
...config,
ios: {
...config.ios,
// iOS uses Apple Maps via PROVIDER_DEFAULT, so this only matters if the
// Map component is switched to PROVIDER_GOOGLE.
config: { ...config.ios?.config, googleMapsApiKey },
infoPlist: {
...config.ios?.infoPlist,
NSLocationWhenInUseUsageDescription: LOCATION_PERMISSION,
},
},
android: {
...config.android,
config: {
...config.android?.config,
googleMaps: { apiKey: googleMapsApiKey },
},
// Location permissions come from the expo-location plugin below.
},
plugins: [
...(config.plugins ?? []),
[
"expo-location",
{
locationAlwaysAndWhenInUsePermission: LOCATION_PERMISSION,
locationWhenInUsePermission: LOCATION_PERMISSION,
},
],
],
};
};
+33 -50
View File
@@ -1,6 +1,4 @@
import * as Location from "expo-location";
import { router } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
@@ -12,22 +10,27 @@ import {
import { SafeAreaView } from "react-native-safe-area-context";
import { GoogleTextInput } from "@/components/google-text-input";
import { LocationNotice } from "@/components/location-notice";
import { Map } from "@/components/map";
import { RideCard } from "@/components/ride-card";
import { ServiceSelector } from "@/components/service-selector";
import { icons, images } from "@/constants";
import { useSession } from "@/lib/session";
import { useUserLocation } from "@/lib/use-user-location";
import { useLocationStore } from "@/store";
import { useFetch } from "@/lib/fetch";
import type { Ride } from "@/types/type";
const Home = () => {
const { setUserLocation, setDestinationLocation } = useLocationStore();
const setDestinationLocation = useLocationStore(
(state) => state.setDestinationLocation,
);
const { signOut, user } = useSession();
const { data: recentRides, loading } = useFetch<Ride[]>(
`/(api)/ride/${user?.id}`,
);
const [hasPermissions, setHasPermissions] = useState(false);
const { status: locationStatus, retry: retryLocation } = useUserLocation();
const handleSignOut = () => {
signOut();
@@ -44,43 +47,6 @@ const Home = () => {
router.push("/(root)/find-ride");
};
useEffect(() => {
const requestLocation = async () => {
try {
let { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") return setHasPermissions(false);
setHasPermissions(true);
let location = await Location.getCurrentPositionAsync();
let addressText = "Unknown location";
try {
const address = await Location.reverseGeocodeAsync({
longitude: location.coords?.longitude,
latitude: location.coords?.latitude,
});
if (address[0]) {
addressText = `${address[0].name}, ${address[0].region}`;
}
} catch (geocodeErr) {
console.log("[REVERSE_GEOCODE]: ", geocodeErr);
}
setUserLocation({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
address: addressText,
});
} catch (err) {
console.log("[LOCATION]: ", err);
setHasPermissions(false);
}
};
requestLocation();
}, [setUserLocation]);
return (
<SafeAreaView className="bg-general-500">
@@ -140,19 +106,36 @@ const Home = () => {
Your Current Location
</Text>
<View className="flex flex-row items-center bg-transparent h-[300px]">
{hasPermissions ? (
<Map />
<View className="w-full h-[300px] rounded-2xl overflow-hidden bg-white">
{locationStatus === "pending" || locationStatus === "granted" ? (
<>
{/* The map draws straight away on the Beirut fallback so the
slot never sits empty while the fix is still coming. */}
<Map />
{locationStatus === "pending" ? (
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 px-4 py-2 shadow-md shadow-neutral-400/40">
<ActivityIndicator size="small" color="#0286ff" />
<Text className="ml-2 text-xs font-JakartaMedium text-general-200">
Finding your location
</Text>
</View>
) : null}
</>
) : (
<View className="flex-1 items-center justify-center bg-white rounded-2xl h-full">
<Text className="text-general-200 text-center font-JakartaMedium px-5">
Location access is off.{"\n"}Enable it in your device
settings to see nearby drivers.
</Text>
</View>
<LocationNotice
status={locationStatus}
onRetry={retryLocation}
/>
)}
</View>
<Text className="text-xl font-JakartaBold mt-5 mb-3">
What do you need?
</Text>
<ServiceSelector />
<Text className="text-xl font-JakartaBold mt-5 mb-3">
Recent Rides
</Text>
+58
View File
@@ -0,0 +1,58 @@
import { Linking, Text, TouchableOpacity, View } from "react-native";
import type { LocationStatus } from "@/lib/use-user-location";
const COPY: Record<string, { title: string; body: string; action: string }> = {
denied: {
title: "Location access is off",
body: "Waseel needs your location to show nearby drivers and set your pickup point.",
action: "Open Settings",
},
"services-off": {
title: "Location services are off",
body: "Turn on location on your device, then try again.",
action: "Try Again",
},
unavailable: {
title: "Couldn't find your location",
body: "Move somewhere with a clearer signal, or set your pickup point manually.",
action: "Try Again",
},
};
/** Fills the map slot when there's no position to draw. */
export const LocationNotice = ({
status,
onRetry,
}: {
status: LocationStatus;
onRetry: () => void;
}) => {
const copy = COPY[status];
if (!copy) return null;
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-base font-JakartaBold text-black text-center">
{copy.title}
</Text>
<Text className="text-sm font-Jakarta text-general-200 text-center mt-2">
{copy.body}
</Text>
<TouchableOpacity
onPress={() =>
status === "denied" ? void Linking.openSettings() : onRetry()
}
activeOpacity={0.8}
className="mt-5 rounded-full bg-primary-500 px-6 py-3"
>
<Text className="text-white font-JakartaBold text-sm">
{copy.action}
</Text>
</TouchableOpacity>
</View>
);
};
+65 -45
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { Platform, StyleSheet } from "react-native";
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
import MapViewDirections from "react-native-maps-directions";
@@ -13,8 +13,34 @@ import {
import { useDriverStore, useLocationStore } from "@/store";
import type { Driver, MarkerData } from "@/types/type";
// react-native-maps sizes itself from a real style object, so give it explicit
// dimensions rather than relying on percentage classNames resolving to 0.
const styles = StyleSheet.create({
map: { ...StyleSheet.absoluteFillObject, borderRadius: 16 },
});
// "mutedStandard" is an Apple Maps type. Android's MapManager looks the value
// up in a fixed table and unboxes the result into an int, so an unrecognised
// name is a null Integer -> NullPointerException, and the map never draws.
const MAP_TYPE = Platform.OS === "ios" ? "mutedStandard" : "standard";
// showsPointsOfInterest is iOS-only; on Android the same muting is done with a
// style array, so both platforms get the same clean base map.
const MUTED_POI_STYLE = [
{
featureType: "poi",
elementType: "labels",
stylers: [{ visibility: "off" }],
},
{
featureType: "transit",
elementType: "labels.icon",
stylers: [{ visibility: "off" }],
},
];
export const Map = () => {
const { data: drivers, loading, error } = useFetch<Driver[]>("/(api)/driver");
const { data: drivers, error } = useFetch<Driver[]>("/(api)/driver");
const {
userLatitude,
@@ -68,28 +94,19 @@ export const Map = () => {
setDrivers,
]);
if (loading || !userLatitude || !userLongitude) {
return (
<View className="flex justify-between items-center w-full">
<ActivityIndicator size="small" color="#000" />
</View>
);
}
if (error) {
return (
<View className="flex justify-between items-center w-full">
<Text>Error: {error}</Text>
</View>
);
}
// The map itself never waits on the driver list or the location fix: drivers
// are an overlay, and calculateRegion falls back to Beirut without coords.
// Previously either one failing replaced the whole map with a spinner or an
// error line, which read as "the map didn't load".
if (error) console.log("[MAP_DRIVERS]: ", error);
return (
<MapView
provider={PROVIDER_DEFAULT}
className="w-full h-full rounded-2xl"
style={styles.map}
tintColor="black"
mapType="mutedStandard"
mapType={MAP_TYPE}
customMapStyle={MUTED_POI_STYLE}
showsPointsOfInterest={false}
initialRegion={region}
showsUserLocation
@@ -109,33 +126,36 @@ export const Map = () => {
/>
))}
{destinationLatitude && destinationLongitude && (
<>
<Marker
key="destination"
coordinate={{
latitude: destinationLatitude,
longitude: destinationLongitude,
}}
title="Destination"
image={icons.pin}
/>
{userLatitude &&
userLongitude &&
destinationLatitude &&
destinationLongitude && (
<>
<Marker
key="destination"
coordinate={{
latitude: destinationLatitude,
longitude: destinationLongitude,
}}
title="Destination"
image={icons.pin}
/>
<MapViewDirections
origin={{
latitude: userLatitude,
longitude: userLongitude,
}}
destination={{
latitude: destinationLatitude,
longitude: destinationLongitude,
}}
apikey={process.env.EXPO_PUBLIC_GOOGLE_API_KEY!}
strokeColor="#0286FF"
strokeWidth={3}
/>
</>
)}
<MapViewDirections
origin={{
latitude: userLatitude,
longitude: userLongitude,
}}
destination={{
latitude: destinationLatitude,
longitude: destinationLongitude,
}}
apikey={process.env.EXPO_PUBLIC_GOOGLE_API_KEY!}
strokeColor="#0286FF"
strokeWidth={3}
/>
</>
)}
</MapView>
);
};
+65
View File
@@ -0,0 +1,65 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import { SERVICES } from "@/constants/services";
import { useServiceStore } from "@/store";
/**
* Service picker: Car / Moto / Courier / My Car.
*
* Four equal tiles across the row rather than a scroller — with only four
* services, anything off-screen is a service riders won't discover. Selection
* styling matches the role picker on sign-up so the two read as the same
* control.
*/
export const ServiceSelector = () => {
const { service, setService } = useServiceStore();
const selected = SERVICES.find((item) => item.id === service);
return (
<View>
<View className="flex-row gap-2">
{SERVICES.map((item) => {
const active = item.id === service;
return (
<TouchableOpacity
key={item.id}
onPress={() => setService(item.id)}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityState={{ selected: active }}
className={`flex-1 items-center rounded-2xl border py-3 ${
active
? "border-primary-500 bg-primary-500/10"
: "border-neutral-100 bg-neutral-100"
}`}
>
<MaterialCommunityIcons
name={item.icon}
size={24}
color={active ? "#0286ff" : "#858585"}
/>
<Text
numberOfLines={1}
className={`mt-1.5 text-xs font-JakartaBold ${
active ? "text-primary-500" : "text-black"
}`}
>
{item.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{selected ? (
<Text className="mt-3 text-sm font-Jakarta text-general-200">
{selected.tagline}
</Text>
) : null}
</View>
);
};
+51
View File
@@ -0,0 +1,51 @@
// The services offered on the home screen.
//
// English-only for now. When the app gets a real language layer these are the
// intended Arabic names, chosen Levantine rather than formal MSA — "موتور" is
// what a motorcycle taxi is actually called in Lebanon, where "دراجة نارية"
// reads like a textbook translation:
// car → سيارة
// moto → موتور
// courier → توصيل طرود
// chauffeur → سائق خاص
export type ServiceId = "car" | "moto" | "courier" | "chauffeur";
export type Service = {
id: ServiceId;
/** MaterialCommunityIcons glyph name. */
icon: "car" | "motorbike" | "package-variant-closed" | "steering";
/** Kept to one short word so four tiles fit a phone width without scrolling. */
label: string;
/** Shown under the row once the service is selected. */
tagline: string;
};
export const SERVICES: Service[] = [
{
id: "car",
icon: "car",
label: "Car",
tagline: "An everyday ride, up to 4 seats.",
},
{
id: "moto",
icon: "motorbike",
label: "Moto",
tagline: "Beat the traffic — one passenger, no luggage.",
},
{
id: "courier",
icon: "package-variant-closed",
label: "Courier",
tagline: "Send a parcel across town without riding along.",
},
{
id: "chauffeur",
icon: "steering",
label: "My Car",
tagline: "A driver comes to you and drives your own car.",
},
];
export const DEFAULT_SERVICE: ServiceId = "car";
+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 };
};
+12
View File
@@ -1,3 +1,4 @@
import { DEFAULT_SERVICE, type ServiceId } from "@/constants/services";
import type { DriverStore, LocationStore, MarkerData } from "@/types/type";
import { create } from "zustand";
@@ -42,6 +43,17 @@ export const useLocationStore = create<LocationStore>((set) => ({
},
}));
type ServiceStore = {
service: ServiceId;
setService: (service: ServiceId) => void;
};
/** Which service the rider picked on the home screen. */
export const useServiceStore = create<ServiceStore>((set) => ({
service: DEFAULT_SERVICE,
setService: (service: ServiceId) => set(() => ({ service })),
}));
export const useDriverStore = create<DriverStore>((set) => ({
drivers: [] as MarkerData[],
selectedDriver: null,