Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.
Camera permission on Android:
- Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
own plugin never declares CAMERA, and Android denies a request for an
undeclared permission instantly and silently — no dialog is ever shown,
which is indistinguishable from the app not asking at all.
- Handle canAskAgain: once Android stops showing the dialog, repeating why
we need it is a dead end, so offer Open Settings instead (lib/capture-
permission.ts), matching what the location flow already did.
Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.
Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|
import * as Location from "expo-location";
|
|
import { router, useLocalSearchParams } from "expo-router";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
|
|
import { CustomButton } from "@/components/custom-button";
|
|
import { PinAdjuster } from "@/components/pin-adjuster";
|
|
import { useT } from "@/lib/i18n";
|
|
import { addressForCoords } from "@/lib/reverse-geocode";
|
|
import { useLocationStore } from "@/store";
|
|
|
|
// "Move the pin to where you actually are."
|
|
//
|
|
// An address from autocomplete lands on whatever the geocoder considers the
|
|
// centre of that place — which can be the wrong side of a building, the wrong
|
|
// end of a long street, or the middle of a junction the driver can't stop in.
|
|
// The rider knows the doorway; this screen lets them say so, for the pickup
|
|
// and the drop-off alike.
|
|
//
|
|
// Reverse geocoding is debounced rather than run on every frame of the pan:
|
|
// the label only has to be right once the map stops.
|
|
const GEOCODE_DEBOUNCE_MS = 450;
|
|
|
|
// Falls back to Beirut, matching the map's own default, so the screen always
|
|
// has somewhere to open even before a fix arrives.
|
|
const FALLBACK = { latitude: 33.8938, longitude: 35.5018 };
|
|
|
|
type Coords = { latitude: number; longitude: number };
|
|
|
|
const AdjustPin = () => {
|
|
const t = useT();
|
|
const params = useLocalSearchParams<{ mode?: string }>();
|
|
const mode = params.mode === "destination" ? "destination" : "origin";
|
|
|
|
const {
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
setUserLocation,
|
|
setDestinationLocation,
|
|
} = useLocationStore();
|
|
|
|
// Open on the point being edited. A destination that hasn't been chosen yet
|
|
// starts at the rider instead of an arbitrary city centre, because the place
|
|
// they're going is usually near the place they are.
|
|
const initial: Coords =
|
|
mode === "origin"
|
|
? {
|
|
latitude: userLatitude ?? FALLBACK.latitude,
|
|
longitude: userLongitude ?? FALLBACK.longitude,
|
|
}
|
|
: {
|
|
latitude: destinationLatitude ?? userLatitude ?? FALLBACK.latitude,
|
|
longitude:
|
|
destinationLongitude ?? userLongitude ?? FALLBACK.longitude,
|
|
};
|
|
|
|
const [coords, setCoords] = useState<Coords>(initial);
|
|
const [address, setAddress] = useState<string | null>(null);
|
|
const [resolving, setResolving] = useState(true);
|
|
const debounce = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
const resolve = useCallback((next: Coords) => {
|
|
setCoords(next);
|
|
clearTimeout(debounce.current);
|
|
|
|
debounce.current = setTimeout(async () => {
|
|
const label = await addressForCoords(next.latitude, next.longitude);
|
|
setAddress(label);
|
|
setResolving(false);
|
|
}, GEOCODE_DEBOUNCE_MS);
|
|
}, []);
|
|
|
|
// Label the point the screen opened on, so the card isn't blank on arrival.
|
|
useEffect(() => {
|
|
resolve(initial);
|
|
return () => clearTimeout(debounce.current);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const confirm = () => {
|
|
const payload = {
|
|
latitude: coords.latitude,
|
|
longitude: coords.longitude,
|
|
address: address ?? t("common.yourLocation"),
|
|
};
|
|
|
|
if (mode === "origin") setUserLocation(payload);
|
|
else setDestinationLocation(payload);
|
|
|
|
router.back();
|
|
};
|
|
|
|
// Jump back to the rider's own position — the usual reason to open this
|
|
// screen is that the suggested pickup drifted away from where they're
|
|
// standing.
|
|
const recenter = async () => {
|
|
try {
|
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
if (status !== "granted") return;
|
|
|
|
const position = await Location.getLastKnownPositionAsync({
|
|
maxAge: 60_000,
|
|
});
|
|
if (!position) return;
|
|
|
|
setResolving(true);
|
|
resolve({
|
|
latitude: position.coords.latitude,
|
|
longitude: position.coords.longitude,
|
|
});
|
|
} catch (error) {
|
|
console.log("[ADJUST_PIN_RECENTER]: ", error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View className="flex-1 bg-white dark:bg-neutral-950">
|
|
<PinAdjuster
|
|
initial={initial}
|
|
onMoveStart={() => setResolving(true)}
|
|
onSettled={resolve}
|
|
/>
|
|
|
|
<SafeAreaView className="flex-1" pointerEvents="box-none">
|
|
<View className="px-5 pt-2" pointerEvents="box-none">
|
|
<TouchableOpacity
|
|
onPress={() => router.back()}
|
|
accessibilityLabel={t("common.back")}
|
|
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
|
|
>
|
|
<MaterialCommunityIcons name="arrow-left" size={20} color="#0286ff" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View className="flex-1" pointerEvents="none" />
|
|
|
|
<View className="px-5 pb-5" pointerEvents="box-none">
|
|
<TouchableOpacity
|
|
onPress={recenter}
|
|
accessibilityLabel={t("adjustPin.recenter")}
|
|
className="self-end mb-3 w-11 h-11 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
|
|
>
|
|
<MaterialCommunityIcons
|
|
name="crosshairs-gps"
|
|
size={20}
|
|
color="#0286ff"
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
<View className="rounded-2xl bg-white dark:bg-neutral-900 p-5 shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
|
|
<Text className="text-xs font-JakartaSemiBold uppercase tracking-wide text-general-200 dark:text-neutral-500 mb-1">
|
|
{mode === "origin"
|
|
? t("adjustPin.pickupLabel")
|
|
: t("adjustPin.destinationLabel")}
|
|
</Text>
|
|
|
|
<View className="flex-row items-center min-h-[26px] mb-1">
|
|
{resolving ? (
|
|
<>
|
|
<ActivityIndicator size="small" color="#0286ff" />
|
|
<Text className="ml-2 font-JakartaMedium text-general-200 dark:text-neutral-400">
|
|
{t("adjustPin.locating")}
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<Text
|
|
className="font-JakartaBold text-black dark:text-white text-base flex-1"
|
|
numberOfLines={2}
|
|
>
|
|
{address}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-4">
|
|
{t("adjustPin.hint")}
|
|
</Text>
|
|
|
|
<CustomButton
|
|
title={
|
|
mode === "origin"
|
|
? t("adjustPin.confirmPickup")
|
|
: t("adjustPin.confirmDestination")
|
|
}
|
|
onPress={confirm}
|
|
disabled={resolving}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</SafeAreaView>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default AdjustPin;
|