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
+7
View File
@@ -63,6 +63,13 @@ const TabsLayout = () => {
tabBarActiveTintColor: "white",
tabBarInactiveTintColor: "white",
tabBarShowLabel: false,
// Get out of the way while someone is typing. The bar floats
// (position: absolute) and Android resizes the window around the
// keyboard, so it doesn't stay at the bottom of the screen — it rides up
// and parks on top of the address suggestions the rider is trying to
// tap, which is the worst possible place for it during a pickup or
// destination search.
tabBarHideOnKeyboard: true,
tabBarStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#333",
borderRadius: 50,
+8 -35
View File
@@ -1,38 +1,11 @@
import { Image, ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ChatThread } from "@/components/chat-thread";
import { images } from "@/constants";
import { useT } from "@/lib/i18n";
// Tab-bar footprint: 78px tall + 20px bottom margin (see (tabs)/_layout.tsx).
// It's position:"absolute" so it reserves no layout space of its own — the
// composer below needs this much extra clearance or the floating pill bar
// sits on top of it.
const TAB_BAR_CLEARANCE = 98;
const Chat = () => {
const t = useT();
const Chat = () => <ChatThread tabBarClearance={TAB_BAR_CLEARANCE} />;
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 p-5">
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{t("chat.title")}
</Text>
<View className="flex-1 h-fit flex justify-center items-center">
<Image
source={images.message}
alt={t("chat.messageAlt")}
className="w-full h-40"
resizeMode="contain"
/>
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
{t("chat.noMessages")}
</Text>
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
{t("chat.startConversation")}
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default Chat;
export default Chat;
+10 -1
View File
@@ -9,6 +9,7 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ActiveRideBanner } from "@/components/active-ride-banner";
import { GoogleTextInput } from "@/components/google-text-input";
import { LocationNotice } from "@/components/location-notice";
import { Map } from "@/components/map";
@@ -28,6 +29,7 @@ const Home = () => {
const setDestinationLocation = useLocationStore(
(state) => state.setDestinationLocation,
);
const clearDestination = useLocationStore((state) => state.clearDestination);
const { signOut, user } = useSession();
const { isDark } = useTheme();
const t = useT();
@@ -36,6 +38,9 @@ const Home = () => {
const { status: locationStatus, retry: retryLocation } = useUserLocation();
const handleSignOut = () => {
// A different person signing in on this phone must not inherit the last
// rider's destination — the store lives in the JS process, not the session.
clearDestination();
signOut();
router.replace("/(auth)/sign-in");
@@ -103,6 +108,10 @@ const Home = () => {
</View>
</View>
{/* Unfinished ride or unrated trip — the way back into a ride the
rider navigated away from. */}
<ActiveRideBanner />
<GoogleTextInput
icon={icons.search}
containerStyles="bg-white dark:bg-neutral-900 shadow-md shadow-neutral-300 dark:shadow-neutral-950/40"
@@ -118,7 +127,7 @@ const Home = () => {
<>
{/* The map draws straight away on the Beirut fallback so the
slot never sits empty while the fix is still coming. */}
<Map />
<Map routeless />
{locationStatus === "pending" ? (
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 dark:bg-neutral-900/95 px-4 py-2 shadow-md shadow-neutral-400/40 dark:shadow-neutral-950/40">
+113 -138
View File
@@ -1,8 +1,15 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useFocusEffect } from "expo-router";
import { Alert, Linking, Platform, ScrollView, Text, View } from "react-native";
import {
Alert,
Linking,
Platform,
ScrollView,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useCallback, useState } from "react";
import { Children, Fragment, useCallback, useState } from "react";
import { SettingsRow } from "@/components/settings-row";
import {
@@ -12,7 +19,6 @@ import {
} from "@/lib/settings";
import { useT } from "@/lib/i18n";
import { useLocationPermission } from "@/lib/use-location-permission";
import { useTheme } from "@/lib/theme";
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
@@ -22,15 +28,45 @@ const SectionHeader = ({ title }: { title: string }) => (
</Text>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{children}
</View>
);
/**
* A grouped settings card. Renders an optional muted description header, then
* its children with an automatic divider between each row — so callers never
* hand-thread `border-t` wrapper Views. Null/conditional children (and arrays
* from `.map`) are flattened by `Children.toArray`, so conditionals like
* `status !== "granted" ? <Row/> : null` and `options.map(...)` both work.
*/
const SettingsCard = ({
description,
children,
}: {
description?: string;
children: React.ReactNode;
}) => {
const rows = Children.toArray(children);
return (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{description ? (
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{description}
</Text>
</View>
) : null}
{rows.map((row, index) => (
<Fragment key={index}>
{index > 0 ? (
<View className="border-t border-neutral-100 dark:border-neutral-800" />
) : null}
{row}
</Fragment>
))}
</View>
);
};
const Settings = () => {
const t = useT();
const { isDark } = useTheme();
const mode = useSettingsStore((state) => state.mode);
const setMode = useSettingsStore((state) => state.setMode);
@@ -63,20 +99,6 @@ const Settings = () => {
? t("settings.maps.statusBlocked")
: t("settings.maps.statusUnknown");
const modeLabel =
mode === "light"
? t("settings.appearance.light")
: mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system");
const langLabel =
lang === "en"
? t("settings.language.en")
: lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr");
const callEmergency = useCallback(async () => {
try {
await Linking.openURL("tel:112");
@@ -158,7 +180,7 @@ const Settings = () => {
{/* 1. Maps & Navigation */}
<SectionHeader title={t("settings.maps.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="map-marker-radius"
title={t("settings.maps.title")}
@@ -167,60 +189,39 @@ const Settings = () => {
value={locationStatusLabel}
/>
{status !== "granted" ? (
<View className="border-t border-neutral-100 dark:border-neutral-800">
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
</View>
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
) : null}
</Card>
</SettingsCard>
{/* 2. Appearance */}
<SectionHeader title={t("settings.appearance.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.appearance.description")}
</Text>
</View>
{appearanceOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.appearance.description")}>
{appearanceOptions.map((option) => (
<SettingsRow
key={option.mode}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
>
<SettingsRow
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
right="value"
value={
mode === option.mode
? isDark
? "✓"
: "✓"
: ""
}
onPress={() => setMode(option.mode)}
/>
</View>
right="check"
selected={mode === option.mode}
onPress={() => setMode(option.mode)}
/>
))}
</Card>
</SettingsCard>
{/* 3. Safety */}
<SectionHeader title={t("settings.safety.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="phone-in-talk"
title={t("settings.safety.call112")}
@@ -230,64 +231,45 @@ const Settings = () => {
onPress={callEmergency}
/>
{safetyTiles.map((tile) => (
<View
<SettingsRow
key={tile.key}
className="border-t border-neutral-100 dark:border-neutral-800"
>
<SettingsRow
icon={tile.icon}
title={tile.title}
subtitle={
expandedSafety === tile.key ? undefined : tile.body
}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
</View>
icon={tile.icon}
title={tile.title}
subtitle={expandedSafety === tile.key ? undefined : tile.body}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
))}
</Card>
</SettingsCard>
{/* 4. Language */}
<SectionHeader title={t("settings.language.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.language.description")}
</Text>
</View>
{languageOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.language.description")}>
{languageOptions.map((option) => (
<SettingsRow
key={option.lang}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
>
<SettingsRow
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
right="value"
value={lang === option.lang ? "✓" : ""}
onPress={() => chooseLanguage(option.lang)}
/>
</View>
right="check"
selected={lang === option.lang}
onPress={() => chooseLanguage(option.lang)}
/>
))}
</Card>
</SettingsCard>
{/* 5. Keep awake */}
<SectionHeader title={t("settings.keepAwake.title")} />
<Card>
{/* 5. General — keep-awake toggle + (Android) display-over-other-apps */}
<SectionHeader title={t("settings.general.title")} />
<SettingsCard>
<SettingsRow
icon="monitor"
title={t("settings.keepAwake.title")}
@@ -296,27 +278,20 @@ const Settings = () => {
switchValue={keepAwake}
onSwitchChange={setKeepAwake}
/>
</Card>
{/* 6. Display over other apps (Android only) */}
{Platform.OS === "android" ? (
<>
<SectionHeader title={t("settings.overlay.title")} />
<Card>
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
</Card>
</>
) : null}
{Platform.OS === "android" ? (
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
) : null}
</SettingsCard>
</ScrollView>
</SafeAreaView>
);