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.7 KiB
TypeScript
200 lines
6.7 KiB
TypeScript
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|
import type * as ImagePicker from "expo-image-picker";
|
|
import { useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Image,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
|
|
import { alertPermissionDenied } from "@/lib/capture-permission";
|
|
import { driverPhotoUri } from "@/lib/driver-photo";
|
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
|
import { loadImagePicker } from "@/lib/image-picker";
|
|
import { useT } from "@/lib/i18n";
|
|
import { useTheme } from "@/lib/theme";
|
|
|
|
type PhotoResponse = { data: { photo: string; attached: boolean } };
|
|
|
|
/**
|
|
* The driver's own photo — the one a rider sees against their name in the list
|
|
* of offers, and checks the arriving driver against.
|
|
*
|
|
* Deliberately not the document scanner: this photo is never read by OCR, it
|
|
* is cropped square because it is rendered in a circle everywhere, and it
|
|
* opens the front camera because it is a picture of a person rather than a
|
|
* piece of paper.
|
|
*
|
|
* Uploading attaches it immediately for a driver who already has a profile, so
|
|
* replacing a bad photo is one tap. During onboarding there is no profile row
|
|
* yet, so the caller keeps the returned name and sends it with the submission.
|
|
*/
|
|
export const ProfilePhotoPicker = ({
|
|
current,
|
|
onUploaded,
|
|
}: {
|
|
/** The photo already on the profile, if any. */
|
|
current?: string | null;
|
|
onUploaded: (photo: string) => void;
|
|
}) => {
|
|
const t = useT();
|
|
const { isDark } = useTheme();
|
|
|
|
const [preview, setPreview] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
// A just-taken photo wins over what the server has, so the driver sees the
|
|
// result of their own tap rather than the picture it replaced.
|
|
const shown = preview ?? driverPhotoUri(current) ?? null;
|
|
|
|
const upload = async (asset: ImagePicker.ImagePickerAsset) => {
|
|
if (!asset.base64) {
|
|
Alert.alert(t("driver.photo.errorTitle"), t("driver.photo.errorBody"));
|
|
return;
|
|
}
|
|
|
|
setBusy(true);
|
|
try {
|
|
const { data } = (await fetchAPI("/(api)/driver/photo", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ image_base64: asset.base64 }),
|
|
})) as PhotoResponse;
|
|
|
|
setPreview(asset.uri);
|
|
onUploaded(data.photo);
|
|
} catch (err) {
|
|
console.log("[DRIVER_PHOTO]: ", err);
|
|
|
|
const code =
|
|
err instanceof ApiError
|
|
? (err.body?.code as string | undefined)
|
|
: undefined;
|
|
|
|
Alert.alert(
|
|
t("driver.photo.errorTitle"),
|
|
code === "IMAGE_TOO_LARGE"
|
|
? t("driver.photo.errorTooLarge")
|
|
: code === "PHOTO_RATE_LIMIT"
|
|
? t("driver.photo.errorRateLimit")
|
|
: code === "UNSUPPORTED_IMAGE"
|
|
? t("driver.photo.errorUnsupported")
|
|
: t("driver.photo.errorBody"),
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
// Camera only — deliberately no gallery option.
|
|
//
|
|
// This photo is the rider's check that the person who pulled up is the
|
|
// person the app sent them, so it has to be a picture of whoever is holding
|
|
// the phone right now. Letting it come from the gallery would let a driver
|
|
// register with someone else's face, or a photo of a photo, and nothing
|
|
// downstream could tell the difference. It is not proof of identity — a
|
|
// determined faker can point the camera at a printout — but it removes the
|
|
// effortless version of that, and it keeps the photo current.
|
|
const capture = async () => {
|
|
if (busy) return;
|
|
|
|
// Loaded on demand — see lib/image-picker. On a binary built before
|
|
// expo-image-picker was added this is the difference between one button
|
|
// not working and the app not starting.
|
|
const picker = loadImagePicker();
|
|
if (!picker) {
|
|
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
|
|
return;
|
|
}
|
|
|
|
// Everything that touches the picker is wrapped: the availability check
|
|
// above should make a missing native module impossible, but a driver must
|
|
// never be shown a raw "Cannot find native module" either way.
|
|
let result: ImagePicker.ImagePickerResult;
|
|
|
|
try {
|
|
const permission = await picker.requestCameraPermissionsAsync();
|
|
|
|
if (!permission.granted) {
|
|
alertPermissionDenied(permission, {
|
|
title: t("driver.photo.permissionTitle"),
|
|
message: t("driver.photo.permissionCamera"),
|
|
blocked: t("driver.photo.permissionCameraBlocked"),
|
|
openSettings: t("common.openSettings"),
|
|
cancel: t("common.cancel"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// No crop step: one tap, done. Every surface renders this in a circle
|
|
// with a centre crop anyway, and a selfie is already centred on the face.
|
|
result = await picker.launchCameraAsync({
|
|
mediaTypes: picker.MediaTypeOptions.Images,
|
|
quality: 0.7,
|
|
base64: true,
|
|
exif: false,
|
|
cameraType: picker.CameraType.front,
|
|
});
|
|
} catch (error) {
|
|
console.log("[DRIVER_PHOTO_CAMERA]: ", error);
|
|
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
|
|
return;
|
|
}
|
|
|
|
if (result.canceled || !result.assets[0]) return;
|
|
|
|
await upload(result.assets[0]);
|
|
};
|
|
|
|
return (
|
|
<View className="items-center mb-6">
|
|
<TouchableOpacity
|
|
onPress={() => void capture()}
|
|
disabled={busy}
|
|
className="w-28 h-28 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center overflow-hidden border-2 border-primary-500"
|
|
>
|
|
{busy ? (
|
|
<ActivityIndicator color="#0286ff" />
|
|
) : shown ? (
|
|
<Image
|
|
source={{ uri: shown }}
|
|
className="w-28 h-28"
|
|
resizeMode="cover"
|
|
/>
|
|
) : (
|
|
<MaterialCommunityIcons
|
|
name="camera-plus-outline"
|
|
size={30}
|
|
color={isDark ? "#9ca3af" : "#858585"}
|
|
/>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
<Text className="text-sm font-JakartaBold text-black dark:text-white mt-3">
|
|
{t("driver.photo.title")}
|
|
</Text>
|
|
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-1 px-6">
|
|
{t("driver.photo.hint")}
|
|
</Text>
|
|
|
|
<TouchableOpacity
|
|
onPress={() => void capture()}
|
|
disabled={busy}
|
|
className="flex-row items-center rounded-full bg-primary-500 py-2.5 px-5 mt-3"
|
|
>
|
|
<MaterialCommunityIcons
|
|
name="camera-outline"
|
|
size={15}
|
|
color="#ffffff"
|
|
/>
|
|
<Text className="text-white font-JakartaBold text-xs ml-1.5">
|
|
{shown ? t("driver.photo.retake") : t("driver.photo.take")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
};
|