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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user