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(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 ( 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 ? ( ) : shown ? ( ) : ( )} {t("driver.photo.title")} {t("driver.photo.hint")} void capture()} disabled={busy} className="flex-row items-center rounded-full bg-primary-500 py-2.5 px-5 mt-3" > {shown ? t("driver.photo.retake") : t("driver.photo.take")} ); };