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 { ApiError, fetchAPI } from "@/lib/fetch"; import { loadImagePicker } from "@/lib/image-picker"; import { useT } from "@/lib/i18n"; import { useTheme } from "@/lib/theme"; /** The three documents a Lebanese driver is vetted against. */ export type DocumentType = "license" | "id" | "vehicle_reg"; /** * What a scan can fill in. Every field is optional and independent: a licence * whose number reads cleanly but whose expiry is smudged yields just the * number. Mirrors ExtractedFields on the server — deliberately redeclared here * so the client bundle doesn't pull in lib/document-ocr.ts, which is Node-only. */ export type ScannedFields = { license_number?: string; license_expiry?: string; national_id?: string; plate_number?: string; car_model?: string; }; type ScanResponse = { data: { doc_type: DocumentType; document: string; fields: ScannedFields; code?: string; }; }; /** * Photographs one document, sends it for OCR, and reports back both the stored * scan's name (which goes with the profile submission) and whatever fields * were read off it. * * The component never writes to the form itself — it hands the values up, and * the form decides what to do with them. That separation is what lets a driver * correct a misread field and not have the next scan silently stamp over it. * A failed read is not an error state here: the scan is still stored for the * reviewer, and the driver types the details in by hand as before. */ export const DocumentScanner = ({ docType, label, hint, optional = false, onFile = false, onScanned, }: { docType: DocumentType; label: string; hint: string; optional?: boolean; /** A scan of this document is already stored — resubmitting may not need a new one. */ onFile?: boolean; onScanned: (document: string, fields: ScannedFields) => void; }) => { const t = useT(); const { isDark } = useTheme(); const [preview, setPreview] = useState(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const [failed, setFailed] = useState(false); const upload = async (asset: ImagePicker.ImagePickerAsset) => { if (!asset.base64) { Alert.alert(t("driver.scan.errorTitle"), t("driver.scan.errorBody")); return; } setBusy(true); setStatus(null); setFailed(false); try { const { data } = (await fetchAPI("/(api)/driver/scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc_type: docType, image_base64: asset.base64 }), })) as ScanResponse; setPreview(asset.uri); onScanned(data.document, data.fields); const filled = Object.values(data.fields).filter(Boolean).length; // Three outcomes worth telling apart: OCR read something, OCR ran and // found nothing usable, or OCR never ran. All three keep the scan; only // the wording changes, because in every case the driver's next move is // to check the fields below. setStatus( filled > 0 ? t("driver.scan.filled", undefined, filled) : data.code === "OCR_UNAVAILABLE" ? t("driver.scan.savedUnreadable") : t("driver.scan.savedNoFields"), ); } catch (err) { console.log("[DOCUMENT_SCAN]: ", err); const code = err instanceof ApiError ? (err.body?.code as string | undefined) : undefined; Alert.alert( t("driver.scan.errorTitle"), code === "IMAGE_TOO_LARGE" ? t("driver.scan.errorTooLarge") : code === "SCAN_RATE_LIMIT" ? t("driver.scan.errorRateLimit") : code === "UNSUPPORTED_IMAGE" ? t("driver.scan.errorUnsupported") : t("driver.scan.errorBody"), ); setFailed(true); } finally { setBusy(false); } }; const capture = async (source: "camera" | "library") => { if (busy) return; // Loaded on demand: on a binary built before expo-image-picker was added // the native module is missing, and importing it at the top of this file // would take the whole app down instead of just this button. const picker = loadImagePicker(); if (!picker) { Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable")); return; } // Ask only for the permission the tapped button actually needs — a driver // who refuses the camera can still pick an existing photo of their papers. let permission: ImagePicker.PermissionResponse; try { permission = source === "camera" ? await picker.requestCameraPermissionsAsync() : await picker.requestMediaLibraryPermissionsAsync(); } catch (error) { console.log("[DOCUMENT_SCAN_PERMISSION]: ", error); Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable")); return; } if (!permission.granted) { alertPermissionDenied(permission, { title: t("driver.scan.permissionTitle"), message: source === "camera" ? t("driver.scan.permissionCamera") : t("driver.scan.permissionLibrary"), blocked: source === "camera" ? t("driver.scan.permissionCameraBlocked") : t("driver.scan.permissionLibraryBlocked"), openSettings: t("common.openSettings"), cancel: t("common.cancel"), }); return; } // `quality: 0.6` keeps a phone photo comfortably under the upload cap // while staying sharp enough to read small print; no cropping step, // because OCR wants the whole card and an edited crop routinely loses the // line the expiry date sits on. const options: ImagePicker.ImagePickerOptions = { mediaTypes: picker.MediaTypeOptions.Images, quality: 0.6, base64: true, exif: false, }; let result: ImagePicker.ImagePickerResult; try { result = source === "camera" ? await picker.launchCameraAsync(options) : await picker.launchImageLibraryAsync(options); } catch (error) { console.log("[DOCUMENT_SCAN_CAPTURE]: ", error); Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable")); return; } if (result.canceled || !result.assets[0]) return; await upload(result.assets[0]); }; const scanned = preview !== null; return ( {label} {optional ? ( {t("driver.scan.optional")} ) : null} {hint} {scanned ? ( {label} ) : null} void capture("camera")} disabled={busy} className="flex-1 flex-row items-center justify-center rounded-full bg-primary-500 py-3 px-2" > {busy ? ( ) : ( <> {scanned ? t("driver.scan.retake") : t("driver.scan.take")} )} void capture("library")} disabled={busy} className="flex-1 flex-row items-center justify-center rounded-full border border-neutral-300 dark:border-neutral-700 py-3 px-2" > {t("driver.scan.choose")} {busy ? ( {t("driver.scan.reading")} ) : status ? ( {status} ) : failed ? ( {t("driver.scan.errorRetry")} ) : onFile ? ( // Resubmitting after a rejection: the reviewer already has a scan, so // say so rather than making the driver wonder whether it was lost. {t("driver.scan.alreadyOnFile")} ) : null} ); };