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>
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
import { useRef } from "react";
|
|
import { Image, StyleSheet, View } from "react-native";
|
|
import MapView, { PROVIDER_DEFAULT, type Region } from "react-native-maps";
|
|
|
|
import { icons } from "@/constants";
|
|
import { useTheme } from "@/lib/theme";
|
|
|
|
// Fine-tuning a pickup or drop-off point.
|
|
//
|
|
// The pin does NOT move — the map moves under it. Dragging a marker means
|
|
// fighting for a few pixels with the same thumb that pans the map, and on a
|
|
// phone the marker spends most of the gesture hidden under the finger holding
|
|
// it. Anchoring the pin to the centre of the screen and sliding the map
|
|
// underneath makes the target the one thing always visible, which is why every
|
|
// ride-hailing app converged on it.
|
|
//
|
|
// The component is deliberately dumb: it reports the centre when the map
|
|
// settles and nothing else. Reverse geocoding, debouncing and confirmation all
|
|
// live on the screen, so this stays reusable for the origin and the
|
|
// destination alike.
|
|
|
|
export type PinAdjusterProps = {
|
|
initial: { latitude: number; longitude: number };
|
|
/** Fired when the map stops moving, with the coordinate under the pin. */
|
|
onSettled: (coords: { latitude: number; longitude: number }) => void;
|
|
/** Fired as soon as a drag starts, to clear a now-stale address label. */
|
|
onMoveStart?: () => void;
|
|
};
|
|
|
|
// Tight enough that the rider is choosing a doorway, not a district.
|
|
const ZOOM_DELTA = 0.004;
|
|
|
|
const styles = StyleSheet.create({
|
|
map: StyleSheet.absoluteFillObject,
|
|
// Sits above the map and ignores touches, so panning still reaches the map.
|
|
pinLayer: {
|
|
...StyleSheet.absoluteFillObject,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
pin: {
|
|
width: 36,
|
|
height: 36,
|
|
// The pin's point is at its bottom edge, but the coordinate we report is
|
|
// the centre of the screen — so lift it by its own height to put the tip,
|
|
// not the middle of the graphic, on the spot being chosen.
|
|
marginBottom: 36,
|
|
},
|
|
// A small ground marker under the tip: without it, on a busy map, it is
|
|
// genuinely hard to tell which pixel the pin is pointing at.
|
|
dot: {
|
|
position: "absolute",
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: "rgba(2,134,255,0.9)",
|
|
borderWidth: 1,
|
|
borderColor: "#ffffff",
|
|
},
|
|
});
|
|
|
|
export const PinAdjuster = ({
|
|
initial,
|
|
onSettled,
|
|
onMoveStart,
|
|
}: PinAdjusterProps) => {
|
|
const { isDark } = useTheme();
|
|
const mapRef = useRef<MapView>(null);
|
|
|
|
const region: Region = {
|
|
latitude: initial.latitude,
|
|
longitude: initial.longitude,
|
|
latitudeDelta: ZOOM_DELTA,
|
|
longitudeDelta: ZOOM_DELTA,
|
|
};
|
|
|
|
return (
|
|
<View style={StyleSheet.absoluteFill}>
|
|
<MapView
|
|
ref={mapRef}
|
|
provider={PROVIDER_DEFAULT}
|
|
style={styles.map}
|
|
initialRegion={region}
|
|
showsUserLocation
|
|
showsMyLocationButton={false}
|
|
userInterfaceStyle={isDark ? "dark" : "light"}
|
|
onPanDrag={onMoveStart}
|
|
onRegionChangeComplete={(next) =>
|
|
onSettled({ latitude: next.latitude, longitude: next.longitude })
|
|
}
|
|
/>
|
|
|
|
<View style={styles.pinLayer} pointerEvents="none">
|
|
<Image source={icons.pin} style={styles.pin} resizeMode="contain" />
|
|
<View style={styles.dot} />
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|