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
+119
-30
@@ -1,47 +1,68 @@
|
||||
import * as Location from "expo-location";
|
||||
import { AppState } from "react-native";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import {
|
||||
fixFromCoords,
|
||||
getLastKnownCoords,
|
||||
pingDriverLocation,
|
||||
setHeartbeatActive,
|
||||
setLastKnownCoords,
|
||||
startBackgroundTracking,
|
||||
stopBackgroundTracking,
|
||||
type DriverFix,
|
||||
} from "@/lib/location-task";
|
||||
import { resetOfferNotifications } from "@/lib/notifications";
|
||||
|
||||
// While the driver is online, watch their position and POST it to the server
|
||||
// as a heartbeat. Each ping both updates the driver's lat/lng and refreshes
|
||||
// last_seen/online, which is what keeps the driver eligible for matching. The
|
||||
// watch is started when `online` flips true and torn down on false/unmount.
|
||||
// last_seen, which is what keeps the driver eligible for matching.
|
||||
//
|
||||
// The watch must also restart when the app returns to the foreground: the OS
|
||||
// suspends location updates in the background, and the subscription we hold
|
||||
// does not auto-revive. Without this, a driver who briefly backgrounds the app
|
||||
// goes permanently stale (last_seen older than the dispatch freshness window)
|
||||
// and stops receiving ride requests until they toggle offline→online again.
|
||||
// Two trackers run, and they do different jobs:
|
||||
//
|
||||
// Pings are throttled to every PING_INTERVAL_MS so a fast-moving driver
|
||||
// doesn't hammer the server, and the location permission is only requested
|
||||
// once the driver actually intends to go online.
|
||||
// Background — expo-location's task-based updates behind an Android
|
||||
// foreground service (lib/location-task.ts). This is the one that matters:
|
||||
// it keeps pinging with the screen off, so a driver who pockets their phone
|
||||
// stays in the match pool instead of going stale within a minute. It also
|
||||
// carries offer notifications back.
|
||||
//
|
||||
// Foreground — a plain watchPositionAsync, purely so this hook can hand the
|
||||
// driver's current coordinates back to the UI for the map. The background
|
||||
// task can't update React state; it runs outside the component tree.
|
||||
//
|
||||
// The foreground watch only pings the server itself when background tracking
|
||||
// was refused (an OS that denied the permission), so the two don't double up.
|
||||
//
|
||||
// The watch must restart when the app returns to the foreground: the OS
|
||||
// suspends location updates in the background and the subscription we hold
|
||||
// does not auto-revive.
|
||||
const PING_INTERVAL_MS = 5000;
|
||||
|
||||
// Returns the driver's last-known position (updated alongside each ping) so
|
||||
// the caller can center a map on it, e.g. to show the rider's pickup point
|
||||
// relative to where the driver actually is.
|
||||
export const useDriverLocation = (online: boolean) => {
|
||||
const subscriptionRef = useRef<Location.LocationSubscription | null>(null);
|
||||
const onlineRef = useRef(online);
|
||||
onlineRef.current = online;
|
||||
// True once the foreground service is running, in which case the foreground
|
||||
// watch is display-only and must not ping as well.
|
||||
const backgroundActive = useRef(false);
|
||||
const [coords, setCoords] = useState<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!online) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const ping = async (latitude: number, longitude: number) => {
|
||||
try {
|
||||
await fetchAPI("/(api)/driver/location", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ latitude, longitude }),
|
||||
});
|
||||
} catch (error) {
|
||||
// A failed ping is non-fatal — the next one will retry. last_seen
|
||||
// going stale is what takes a driver out of the match pool, not a 500.
|
||||
console.log("[DRIVER_LOCATION_PING]: ", error);
|
||||
}
|
||||
// A new position updates the map and the shared last-known value. It does
|
||||
// NOT ping on its own — see the heartbeat effect below for why.
|
||||
const record = (fix: DriverFix) => {
|
||||
setCoords({ latitude: fix.latitude, longitude: fix.longitude });
|
||||
setLastKnownCoords(fix);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
@@ -50,23 +71,39 @@ export const useDriverLocation = (online: boolean) => {
|
||||
|
||||
if (!(await Location.hasServicesEnabledAsync())) return;
|
||||
|
||||
// "Allow all the time" is requested but not required: the foreground
|
||||
// service is what actually keeps updates flowing on Android, and a
|
||||
// driver who only grants "while using" still gets tracked while the
|
||||
// service notification is up.
|
||||
try {
|
||||
await Location.requestBackgroundPermissionsAsync();
|
||||
} catch (error) {
|
||||
console.log("[DRIVER_LOCATION_BG_PERMISSION]: ", error);
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
backgroundActive.current = await startBackgroundTracking();
|
||||
|
||||
// Seed the server with the last known position immediately, so the
|
||||
// driver is matchable without waiting for the first watch callback.
|
||||
const cached = await Location.getLastKnownPositionAsync({
|
||||
maxAge: 5 * 60 * 1000,
|
||||
});
|
||||
if (!cancelled && cached) {
|
||||
void ping(cached.coords.latitude, cached.coords.longitude);
|
||||
record(fixFromCoords(cached.coords));
|
||||
}
|
||||
|
||||
const subscription = await Location.watchPositionAsync(
|
||||
{
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
timeInterval: PING_INTERVAL_MS,
|
||||
distanceInterval: 20,
|
||||
// 0, not a displacement threshold — see the note in
|
||||
// lib/location-task.ts. A stationary driver must keep reporting, or
|
||||
// dispatch treats them as gone.
|
||||
distanceInterval: 0,
|
||||
},
|
||||
({ coords }) => {
|
||||
if (!cancelled) void ping(coords.latitude, coords.longitude);
|
||||
if (!cancelled) record(fixFromCoords(coords));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -86,8 +123,9 @@ export const useDriverLocation = (online: boolean) => {
|
||||
void start();
|
||||
|
||||
// Restart the watch whenever the app comes back to the foreground. While
|
||||
// backgrounded the OS pauses location updates and the old subscription is
|
||||
// dead; without re-starting it the driver never pings again.
|
||||
// backgrounded the OS pauses the in-process watch and the old
|
||||
// subscription is dead; the task-based updates carry on regardless, so
|
||||
// this only restores the coordinates the UI draws with.
|
||||
const onAppStateChange = (state: string) => {
|
||||
if (state !== "active") return;
|
||||
if (!onlineRef.current) return;
|
||||
@@ -100,6 +138,57 @@ export const useDriverLocation = (online: boolean) => {
|
||||
cancelled = true;
|
||||
stop();
|
||||
subscription.remove();
|
||||
backgroundActive.current = false;
|
||||
// Going offline must also tear down the foreground service, or the
|
||||
// driver is left with a "you're online" notification and a GPS drain
|
||||
// for a shift that has ended.
|
||||
void stopBackgroundTracking();
|
||||
resetOfferNotifications();
|
||||
};
|
||||
}, [online]);
|
||||
};
|
||||
|
||||
// The heartbeat.
|
||||
//
|
||||
// This is a plain timer, deliberately, and it is the thing that keeps a
|
||||
// driver in the match pool. Tying the heartbeat to position callbacks —
|
||||
// which is what this did before — meant liveness depended on the OS deciding
|
||||
// to emit a new fix, and a driver parked at a stand with the phone on the
|
||||
// dashboard emits nothing at all: Android's fused provider had no reason to
|
||||
// wake, so the pings stopped, last_seen aged past 60 seconds and the driver
|
||||
// silently vanished from every rider's map while their own screen still read
|
||||
// "Online — receiving ride requests".
|
||||
//
|
||||
// "Where is the driver" and "is the driver still there" are separate
|
||||
// questions, and only the second one has a deadline. So the timer re-sends
|
||||
// the last known position on a fixed cadence whether or not the car has
|
||||
// moved — a stationary driver reporting the same coordinates is exactly the
|
||||
// signal dispatch needs.
|
||||
useEffect(() => {
|
||||
if (!online) return;
|
||||
|
||||
setHeartbeatActive(true);
|
||||
|
||||
const beat = async () => {
|
||||
const position = getLastKnownCoords();
|
||||
if (!position) return;
|
||||
|
||||
try {
|
||||
await pingDriverLocation(position);
|
||||
} catch (error) {
|
||||
// Non-fatal — the next beat retries. A dropped ping only matters if
|
||||
// enough of them drop in a row to age last_seen out.
|
||||
console.log("[DRIVER_HEARTBEAT]: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
void beat();
|
||||
const timer = setInterval(() => void beat(), PING_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
setHeartbeatActive(false);
|
||||
};
|
||||
}, [online]);
|
||||
|
||||
return coords;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user