diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..fbfa41d --- /dev/null +++ b/app.config.js @@ -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, + }, + ], + ], + }; +}; diff --git a/app/(root)/(tabs)/home.tsx b/app/(root)/(tabs)/home.tsx index e779eb9..3f066e9 100644 --- a/app/(root)/(tabs)/home.tsx +++ b/app/(root)/(tabs)/home.tsx @@ -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( `/(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 ( @@ -140,19 +106,36 @@ const Home = () => { Your Current Location - - {hasPermissions ? ( - + + {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. */} + + + {locationStatus === "pending" ? ( + + + + Finding your location… + + + ) : null} + ) : ( - - - Location access is off.{"\n"}Enable it in your device - settings to see nearby drivers. - - + )} + + What do you need? + + + + Recent Rides diff --git a/components/location-notice.tsx b/components/location-notice.tsx new file mode 100644 index 0000000..1006151 --- /dev/null +++ b/components/location-notice.tsx @@ -0,0 +1,58 @@ +import { Linking, Text, TouchableOpacity, View } from "react-native"; + +import type { LocationStatus } from "@/lib/use-user-location"; + +const COPY: Record = { + 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 ( + + + {copy.title} + + + + {copy.body} + + + + status === "denied" ? void Linking.openSettings() : onRetry() + } + activeOpacity={0.8} + className="mt-5 rounded-full bg-primary-500 px-6 py-3" + > + + {copy.action} + + + + ); +}; diff --git a/components/map.tsx b/components/map.tsx index edb744f..3ddc509 100644 --- a/components/map.tsx +++ b/components/map.tsx @@ -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("/(api)/driver"); + const { data: drivers, error } = useFetch("/(api)/driver"); const { userLatitude, @@ -68,28 +94,19 @@ export const Map = () => { setDrivers, ]); - if (loading || !userLatitude || !userLongitude) { - return ( - - - - ); - } - - if (error) { - return ( - - Error: {error} - - ); - } + // 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 ( { /> ))} - {destinationLatitude && destinationLongitude && ( - <> - + {userLatitude && + userLongitude && + destinationLatitude && + destinationLongitude && ( + <> + - - - )} + + + )} ); }; diff --git a/components/service-selector.tsx b/components/service-selector.tsx new file mode 100644 index 0000000..a11c865 --- /dev/null +++ b/components/service-selector.tsx @@ -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 ( + + + {SERVICES.map((item) => { + const active = item.id === service; + + return ( + 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" + }`} + > + + + + {item.label} + + + ); + })} + + + {selected ? ( + + {selected.tagline} + + ) : null} + + ); +}; diff --git a/constants/services.ts b/constants/services.ts new file mode 100644 index 0000000..c1c6e01 --- /dev/null +++ b/constants/services.ts @@ -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"; diff --git a/lib/use-user-location.ts b/lib/use-user-location.ts new file mode 100644 index 0000000..3cd78e9 --- /dev/null +++ b/lib/use-user-location.ts @@ -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 = (promise: Promise, ms: number): Promise => + Promise.race([ + promise, + new Promise((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("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 }; +}; diff --git a/store/index.ts b/store/index.ts index 3e9882e..3d010d4 100644 --- a/store/index.ts +++ b/store/index.ts @@ -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((set) => ({ }, })); +type ServiceStore = { + service: ServiceId; + setService: (service: ServiceId) => void; +}; + +/** Which service the rider picked on the home screen. */ +export const useServiceStore = create((set) => ({ + service: DEFAULT_SERVICE, + setService: (service: ServiceId) => set(() => ({ service })), +})); + export const useDriverStore = create((set) => ({ drivers: [] as MarkerData[], selectedDriver: null,