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,199 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user