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,320 @@
|
||||
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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(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 (
|
||||
<View className="bg-neutral-100 dark:bg-neutral-900 rounded-2xl p-4 mb-4">
|
||||
<View className="flex-row items-start justify-between mb-1">
|
||||
<Text className="text-sm font-JakartaBold text-black dark:text-white flex-1 pr-2">
|
||||
{label}
|
||||
</Text>
|
||||
{optional ? (
|
||||
<Text className="text-[11px] font-JakartaSemiBold text-general-200 dark:text-neutral-500 uppercase">
|
||||
{t("driver.scan.optional")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-3">
|
||||
{hint}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center">
|
||||
{scanned ? (
|
||||
<Image
|
||||
source={{ uri: preview }}
|
||||
className="w-16 h-16 rounded-xl mr-3"
|
||||
resizeMode="cover"
|
||||
alt={label}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View className="flex-1 flex-row gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture("camera")}
|
||||
disabled={busy}
|
||||
className="flex-1 flex-row items-center justify-center rounded-full bg-primary-500 py-3 px-2"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={16}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<Text className="text-white font-JakartaBold text-xs ml-1.5">
|
||||
{scanned ? t("driver.scan.retake") : t("driver.scan.take")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => 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"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="image-outline"
|
||||
size={16}
|
||||
color={isDark ? "#e5e5e5" : "#333333"}
|
||||
/>
|
||||
<Text className="text-black dark:text-white font-JakartaBold text-xs ml-1.5">
|
||||
{t("driver.scan.choose")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{busy ? (
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mt-3">
|
||||
{t("driver.scan.reading")}
|
||||
</Text>
|
||||
) : status ? (
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="check-circle-outline"
|
||||
size={14}
|
||||
color="#10b981"
|
||||
/>
|
||||
<Text className="text-xs font-JakartaSemiBold text-emerald-600 dark:text-emerald-400 ml-1.5 flex-1">
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
) : failed ? (
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="alert-outline"
|
||||
size={14}
|
||||
color="#f43f5e"
|
||||
/>
|
||||
<Text className="text-xs font-JakartaSemiBold text-rose-500 ml-1.5 flex-1">
|
||||
{t("driver.scan.errorRetry")}
|
||||
</Text>
|
||||
</View>
|
||||
) : onFile ? (
|
||||
// Resubmitting after a rejection: the reviewer already has a scan, so
|
||||
// say so rather than making the driver wonder whether it was lost.
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="paperclip"
|
||||
size={14}
|
||||
color={isDark ? "#9ca3af" : "#858585"}
|
||||
/>
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 ml-1.5 flex-1">
|
||||
{t("driver.scan.alreadyOnFile")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user