Files
waseel/app/(root)/adjust-pin.tsx
T
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
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>
2026-08-26 02:17:55 +03:00

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;