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
+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>
);
};