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:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+320
View File
@@ -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>
);
};