Files
waseel/components/profile-photo-picker.tsx
T
KrikoriosandClaude Opus 5 8807ff41c5 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>
2026-08-26 02:17:55 +03:00

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>
);
};