feat: implement core delivery partner dashboard with live location tracking, socket connectivity, and availability status toggling

This commit is contained in:
Tamojit Biswas 2026-07-13 18:35:17 +05:30
parent a6c713ae8c
commit 98e28742de
37 changed files with 2443 additions and 1272 deletions

View File

@ -1,3 +1,4 @@
import React, { useEffect, useRef } from 'react';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native'; import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
import { import {
SafeAreaProvider, SafeAreaProvider,
@ -5,10 +6,11 @@ import {
} from 'react-native-safe-area-context'; } from 'react-native-safe-area-context';
import RootNavigator from '@navigation/rootNavigator'; import RootNavigator from '@navigation/rootNavigator';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
// import { store } from '@store';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { persistor, store } from '@store'; import { persistor, store, useAppSelector } from '@store';
import { PersistGate } from 'redux-persist/integration/react'; import { PersistGate } from 'redux-persist/integration/react';
import { socketService } from '@services';
import { checkPendingOfferThunk } from '@store/commonReducers/delivery';
function App() { function App() {
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
@ -28,6 +30,27 @@ function App() {
function AppContent() { function AppContent() {
const safeAreaInsets = useSafeAreaInsets(); const safeAreaInsets = useSafeAreaInsets();
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const accessToken = useAppSelector(state => state.auth.accessToken);
const prevTokenRef = useRef<string | null>(null);
// Bootstrap the socket service once with the store reference.
// The navigationRef is managed inside RootNavigator, so we pass null here;
// it will be injected from rootNavigator.tsx.
useEffect(() => {
socketService.bootstrap(store, null);
}, []);
// Connect / disconnect the socket when the auth token changes.
useEffect(() => {
if (accessToken && accessToken !== prevTokenRef.current) {
socketService.connect(accessToken);
// Check for any delivery offer the server held while the app was closed
store.dispatch(checkPendingOfferThunk());
} else if (!accessToken && prevTokenRef.current) {
socketService.disconnect();
}
prevTokenRef.current = accessToken;
}, [accessToken]);
return ( return (
<View <View

View File

@ -1,4 +1,9 @@
import { ToggleStatusPayload, ToggleStatusResponse } from '@interfaces'; import {
DeliveryPartnerLocationRequest,
DeliveryPartnerLocationResponse,
ToggleStatusPayload,
ToggleStatusResponse,
} from '@interfaces';
import { apiClient } from '@services'; import { apiClient } from '@services';
export const toggleStatus = async ( export const toggleStatus = async (
@ -10,3 +15,13 @@ export const toggleStatus = async (
); );
return response; return response;
}; };
export const deliveryPartnerLocation = async (
payload: DeliveryPartnerLocationRequest,
): Promise<DeliveryPartnerLocationResponse> => {
const response = await apiClient.post<DeliveryPartnerLocationResponse>(
'/delivery-partners/location',
payload,
);
return response;
};

40
app/api/deliveryApi.ts Normal file
View File

@ -0,0 +1,40 @@
import {
DeliveryAction,
RespondToOfferResponse,
PendingOfferResponse,
ActiveOrdersResponse,
} from '@interfaces';
import { apiClient } from '@services';
/**
* POST /delivery-partners/deliveries/{id}/respond
* Accept or reject a delivery offer.
*/
export const respondToOffer = async (
deliveryId: string,
action: DeliveryAction,
): Promise<RespondToOfferResponse> => {
const response = await apiClient.post<RespondToOfferResponse>(
`/delivery-partners/deliveries/${deliveryId}/respond`,
{ action },
);
return response;
};
/**
* GET /delivery-partners/deliveries/pending-offer
* Check if there is a pending offer for the current partner (e.g. after app restart).
*/
export const getPendingOffer = async (): Promise<PendingOfferResponse> => {
const response = await apiClient.get<PendingOfferResponse>(
'/delivery-partners/deliveries/pending-offer',
);
return response;
};
export const getActiveDeliveries = async (): Promise<ActiveOrdersResponse> => {
const response = await apiClient.get<ActiveOrdersResponse>(
'/delivery-partners/deliveries/active',
);
return response;
};

View File

@ -2,3 +2,4 @@ export * from './authApi';
export * from './onBoardApi'; export * from './onBoardApi';
export * from './kycApi'; export * from './kycApi';
export * from './dashboardApi'; export * from './dashboardApi';
export * from './deliveryApi';

View File

@ -11,8 +11,8 @@ interface OrderCardProps {
pickupAddress: string; pickupAddress: string;
dropName: string; dropName: string;
dropAddress: string; dropAddress: string;
amount: number; amount: string;
distance: number; distance: string;
itemCount?: number; itemCount?: number;
paymentType?: string; paymentType?: string;
onPress?: () => void; onPress?: () => void;

View File

@ -12,15 +12,17 @@ import { getStyles } from './arrivedAtStoreScreen.styles';
export const ArrivedAtStoreScreen: React.FC = () => { export const ArrivedAtStoreScreen: React.FC = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>(); const navigation =
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
const activeJob = useAppSelector((state) => state.job.activeJob); // const activeJob = useAppSelector(state => state.job.activeJob);
const { activeDeliveries } = useAppSelector(state => state.deliveries);
const handleConfirmArrival = () => { const handleConfirmArrival = () => {
navigation.navigate(RouteNames.ConfirmPickup); navigation.navigate(RouteNames.ConfirmPickup);
}; };
if (!activeJob) return null; // if (!activeJob) return null;
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -32,8 +34,12 @@ export const ArrivedAtStoreScreen: React.FC = () => {
{/* Arrived Card */} {/* Arrived Card */}
<View style={styles.bottomCard}> <View style={styles.bottomCard}>
<Text style={styles.cardTitle}>You've arrived</Text> <Text style={styles.cardTitle}>You've arrived</Text>
<Text style={styles.storeName}>{activeJob.pickupName}</Text> <Text style={styles.storeName}>
<Text style={styles.storeAddress}>{activeJob.pickupAddress}</Text> {activeDeliveries?.order?.pickupAddress?.city}
</Text>
<Text style={styles.storeAddress}>
{activeDeliveries?.order?.pickupAddress?.addressLine1}
</Text>
<PrimaryButton <PrimaryButton
title="Confirm Arrival" title="Confirm Arrival"

View File

@ -14,9 +14,10 @@ export const ConfirmPickupScreen: React.FC = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>(); const navigation =
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
const activeJob = useAppSelector((state) => state.job.activeJob); const activeJob = useAppSelector(state => state.job.activeJob);
// Togglable checklist for food/merchandise items // Togglable checklist for food/merchandise items
const [checkedItems, setCheckedItems] = useState<{ [key: number]: boolean }>({ const [checkedItems, setCheckedItems] = useState<{ [key: number]: boolean }>({
@ -30,7 +31,7 @@ export const ConfirmPickupScreen: React.FC = () => {
]; ];
const handleToggleCheck = (id: number) => { const handleToggleCheck = (id: number) => {
setCheckedItems((prev) => ({ setCheckedItems(prev => ({
...prev, ...prev,
[id]: !prev[id], [id]: !prev[id],
})); }));
@ -38,9 +39,12 @@ export const ConfirmPickupScreen: React.FC = () => {
const handlePickedUp = () => { const handlePickedUp = () => {
// Ensure all items are checked before picking up // Ensure all items are checked before picking up
const allChecked = items.every((item) => checkedItems[item.id]); const allChecked = items.every(item => checkedItems[item.id]);
if (!allChecked) { if (!allChecked) {
Alert.alert('Incomplete Verification', 'Please confirm that you have checked all items before picking up.'); Alert.alert(
'Incomplete Verification',
'Please confirm that you have checked all items before picking up.',
);
return; return;
} }
@ -49,10 +53,13 @@ export const ConfirmPickupScreen: React.FC = () => {
}; };
const handleReportIssue = () => { const handleReportIssue = () => {
Alert.alert('Report Issue', 'Helpdesk notified. Support agent will contact you shortly.'); Alert.alert(
'Report Issue',
'Helpdesk notified. Support agent will contact you shortly.',
);
}; };
if (!activeJob) return null; // if (!activeJob) return null;
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -62,10 +69,13 @@ export const ConfirmPickupScreen: React.FC = () => {
onBack={() => navigation.goBack()} onBack={() => navigation.goBack()}
/> />
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}> <ScrollView
contentContainerStyle={styles.scrollContainer}
showsVerticalScrollIndicator={false}
>
{/* Order ID Banner */} {/* Order ID Banner */}
<View style={styles.orderHeader}> <View style={styles.orderHeader}>
<Text style={styles.orderIdText}>Order ID: {activeJob.orderId}</Text> <Text style={styles.orderIdText}>Order ID: {activeJob?.orderId}</Text>
<View style={styles.paymentTag}> <View style={styles.paymentTag}>
<Text style={styles.paymentTagText}>ONLINE PAID</Text> <Text style={styles.paymentTagText}>ONLINE PAID</Text>
</View> </View>
@ -74,21 +84,28 @@ export const ConfirmPickupScreen: React.FC = () => {
{/* Merchant Info */} {/* Merchant Info */}
<View style={styles.merchantInfoCard}> <View style={styles.merchantInfoCard}>
<Text style={styles.merchantLabel}>Pickup Store</Text> <Text style={styles.merchantLabel}>Pickup Store</Text>
<Text style={styles.merchantName}>{activeJob.pickupName}</Text> <Text style={styles.merchantName}>{activeJob?.pickupName}</Text>
</View> </View>
{/* Items Checklist */} {/* Items Checklist */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Verify Items ({items.length})</Text> <Text style={styles.sectionTitle}>Verify Items ({items.length})</Text>
{items.map((item) => ( {items.map(item => (
<TouchableOpacity <TouchableOpacity
key={item.id} key={item.id}
style={styles.itemRow} style={styles.itemRow}
activeOpacity={0.8} activeOpacity={0.8}
onPress={() => handleToggleCheck(item.id)} onPress={() => handleToggleCheck(item.id)}
> >
<View style={[styles.checkbox, checkedItems[item.id] && styles.checkboxChecked]}> <View
{checkedItems[item.id] && <Text style={styles.checkSign}></Text>} style={[
styles.checkbox,
checkedItems[item.id] && styles.checkboxChecked,
]}
>
{checkedItems[item.id] && (
<Text style={styles.checkSign}></Text>
)}
</View> </View>
<Text style={styles.itemNameText}>{item.name}</Text> <Text style={styles.itemNameText}>{item.name}</Text>
<Text style={styles.itemQtyText}>x{item.qty}</Text> <Text style={styles.itemQtyText}>x{item.qty}</Text>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { import {
View, View,
Text, Text,
@ -6,21 +6,25 @@ import {
TouchableOpacity, TouchableOpacity,
Alert, Alert,
Switch, Switch,
InteractionManager,
} from 'react-native'; } from 'react-native';
import { useAppTheme, borderRadius, spacing, shadow, typography } from '@theme'; import { useAppTheme, borderRadius, spacing, shadow, typography } from '@theme';
import { PrimaryButton } from '@components'; import { PrimaryButton } from '@components';
import { BellIcon, PersonIcon, WalletIcon, ClipboardIcon } from '@icons'; import { BellIcon, PersonIcon, WalletIcon, ClipboardIcon } from '@icons';
import { useAppDispatch, useAppSelector } from '@store'; import { useAppDispatch, useAppSelector } from '@store';
import { setOnlineStatus } from '@store/commonReducers/auth'; import { setOnlineStatus } from '@store/commonReducers/auth';
import { setNewJobOffer } from '@store/commonReducers/job';
import { mockJobOffer } from '@store/mockData/mockJobs';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { AppStackParamList } from '@navigation/navigationTypes'; import { AppStackParamList } from '@navigation/navigationTypes';
import { RouteNames, JobStatus } from '@utils/constants'; import { RouteNames, JobStatus } from '@utils/constants';
import { formatCurrency } from '@utils/formatters'; import { formatCurrency } from '@utils/formatters';
import { getStyles } from './dashboardScreen.styles'; import { getStyles } from './dashboardScreen.styles';
import { toggleStatus } from './thunk'; import { toggleStatus, updateDeliveryPartnerLocation } from './thunk';
import {
getCurrentLocationWithAddress,
showPermissionDeniedAlert,
socketService,
} from '@services';
export const DashboardScreen: React.FC = () => { export const DashboardScreen: React.FC = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@ -34,6 +38,7 @@ export const DashboardScreen: React.FC = () => {
const { availabiltyStatus, loading: toggleLoading } = useAppSelector( const { availabiltyStatus, loading: toggleLoading } = useAppSelector(
state => state.dashboard, state => state.dashboard,
); );
const { user } = useAppSelector(state => state.auth);
const authIsOnline = useAppSelector(state => state.auth.isOnline); const authIsOnline = useAppSelector(state => state.auth.isOnline);
const isOnline = availabiltyStatus const isOnline = availabiltyStatus
? availabiltyStatus.availabilityStatus === 'ONLINE' ? availabiltyStatus.availabilityStatus === 'ONLINE'
@ -42,28 +47,57 @@ export const DashboardScreen: React.FC = () => {
useAppSelector(state => state.earnings); useAppSelector(state => state.earnings);
const { jobStatus, activeJob } = useAppSelector(state => state.job); const { jobStatus, activeJob } = useAppSelector(state => state.job);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const locationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const sendLocationUpdate = useCallback(async () => {
try {
const result = await getCurrentLocationWithAddress();
const coords = result.coords;
dispatch(
updateDeliveryPartnerLocation({
latitude: coords.latitude,
longitude: coords.longitude,
accuracyMeters: coords.accuracy ?? 0,
speedKph: coords.speed ? coords.speed * 3.6 : 0, // convert m/s -> km/h if needed
heading: coords.heading ?? 0,
}),
);
} catch (err) {
console.log('--------location update error------', err);
}
}, [dispatch]);
// Background simulation: when online and idle, trigger new order offer after 12 seconds
useEffect(() => { useEffect(() => {
if (isOnline && jobStatus === JobStatus.Idle) { if (isOnline) {
timerRef.current = setTimeout(() => { // Fire one immediately on going online, then every 15s
dispatch(setNewJobOffer(mockJobOffer)); sendLocationUpdate();
navigation.navigate(RouteNames.NewJobRequest); locationIntervalRef.current = setInterval(sendLocationUpdate, 15000);
}, 12000);
} else { } else {
if (timerRef.current) { if (locationIntervalRef.current) {
clearTimeout(timerRef.current); clearInterval(locationIntervalRef.current);
timerRef.current = null; locationIntervalRef.current = null;
} }
} }
return () => { return () => {
if (timerRef.current) { if (locationIntervalRef.current) {
clearTimeout(timerRef.current); clearInterval(locationIntervalRef.current);
locationIntervalRef.current = null;
} }
}; };
}, [isOnline, jobStatus, dispatch, navigation]); }, [isOnline, sendLocationUpdate]);
// Notify the socket server when online/offline status changes
useEffect(() => {
if (isOnline) {
socketService.goOnline();
} else {
socketService.goOffline();
}
}, [isOnline]);
const handleToggleOnline = () => { const handleToggleOnline = () => {
const newStatus = isOnline ? 'OFFLINE' : 'ONLINE'; const newStatus = isOnline ? 'OFFLINE' : 'ONLINE';
@ -88,22 +122,6 @@ export const DashboardScreen: React.FC = () => {
}); });
}; };
const handleSimulateOffer = () => {
if (!isOnline) {
Alert.alert('Go Online', 'Please go online to receive order offers.');
return;
}
if (jobStatus !== JobStatus.Idle) {
Alert.alert(
'Active Delivery',
'You already have an active job offer or order.',
);
return;
}
dispatch(setNewJobOffer(mockJobOffer));
navigation.navigate(RouteNames.NewJobRequest);
};
const handleActiveJobBannerPress = () => { const handleActiveJobBannerPress = () => {
// Navigate back to the screen corresponding to the current job step // Navigate back to the screen corresponding to the current job step
switch (jobStatus) { switch (jobStatus) {
@ -151,7 +169,7 @@ export const DashboardScreen: React.FC = () => {
<View style={styles.header}> <View style={styles.header}>
<View> <View>
<Text style={styles.greetingText}>Good Morning </Text> <Text style={styles.greetingText}>Good Morning </Text>
<Text style={styles.nameText}>Rahul Kumar</Text> <Text style={styles.nameText}>{user?.name}</Text>
</View> </View>
<View style={styles.toggleWrapper}> <View style={styles.toggleWrapper}>
@ -281,17 +299,6 @@ export const DashboardScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* Developer Simulation trigger */}
<TouchableOpacity
style={styles.simulateBtn}
activeOpacity={0.8}
onPress={handleSimulateOffer}
>
<Text style={styles.simulateBtnText}>
Simulate Immediate Job Offer
</Text>
</TouchableOpacity>
<PrimaryButton <PrimaryButton
title="Go Offline" title="Go Offline"
onPress={handleToggleOnline} onPress={handleToggleOnline}

View File

@ -1,17 +1,24 @@
import { ToggleStatusResponse } from '@interfaces'; import {
DeliveryPartnerLocationResponse,
ToggleStatusResponse,
} from '@interfaces';
import { createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { toggleStatus } from './thunk'; import { toggleStatus, updateDeliveryPartnerLocation } from './thunk';
export interface ToggleState { export interface ToggleState {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
availabiltyStatus: ToggleStatusResponse | null; availabiltyStatus: ToggleStatusResponse | null;
deliveryPartnerLocation: DeliveryPartnerLocationResponse | null;
deliveryLocError: string | null;
} }
export const initialState: ToggleState = { export const initialState: ToggleState = {
loading: false, loading: false,
error: null, error: null,
availabiltyStatus: null, availabiltyStatus: null,
deliveryPartnerLocation: null,
deliveryLocError: null,
}; };
export const dashBoardReducer = createReducer(initialState, builder => { export const dashBoardReducer = createReducer(initialState, builder => {
@ -27,5 +34,11 @@ export const dashBoardReducer = createReducer(initialState, builder => {
.addCase(toggleStatus.rejected, (state, action) => { .addCase(toggleStatus.rejected, (state, action) => {
state.loading = false; state.loading = false;
state.error = action.payload as string; state.error = action.payload as string;
})
.addCase(updateDeliveryPartnerLocation.fulfilled, (state, action) => {
state.deliveryPartnerLocation = action.payload;
})
.addCase(updateDeliveryPartnerLocation.rejected, (state, action) => {
state.deliveryLocError = action.payload as string;
}); });
}); });

View File

@ -1,6 +1,9 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { toggleStatus as toggleStatusApi } from '@api'; import { deliveryPartnerLocation, toggleStatus as toggleStatusApi } from '@api';
import { ToggleStatusPayload } from '@interfaces'; import {
DeliveryPartnerLocationRequest,
ToggleStatusPayload,
} from '@interfaces';
export const toggleStatus = createAsyncThunk( export const toggleStatus = createAsyncThunk(
'dashboard/toggleStatus', 'dashboard/toggleStatus',
@ -13,3 +16,15 @@ export const toggleStatus = createAsyncThunk(
} }
}, },
); );
export const updateDeliveryPartnerLocation = createAsyncThunk(
'dashboard/updateDeliveryPartnerLocation',
async (payload: DeliveryPartnerLocationRequest, { rejectWithValue }) => {
try {
const response = await deliveryPartnerLocation(payload);
return response;
} catch (error: unknown) {
return rejectWithValue(error);
}
},
);

View File

@ -1,2 +1,4 @@
export { default } from './jobsScreen'; export { default } from './jobsScreen';
export * from './jobsScreen'; export * from './jobsScreen';
export * from './thunk';
export * from './reducer';

View File

@ -1,17 +1,30 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity } from 'react-native'; import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { OrderCard, EmptyState } from '@components'; import { OrderCard, EmptyState } from '@components';
import { useAppSelector } from '@store'; import { useAppDispatch, useAppSelector } from '@store';
import { ClipboardIcon } from '@icons'; import { ClipboardIcon } from '@icons';
import { getStyles } from './jobsScreen.styles'; import { getStyles } from './jobsScreen.styles';
import { getAllActiveDeliveries } from './thunk';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { AppStackParamList } from '@navigation/navigationTypes';
import { RouteNames } from '@utils/constants';
export const JobsScreen: React.FC = () => { export const JobsScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const dispatch = useAppDispatch();
const [activeTab, setActiveTab] = useState<'active' | 'history'>('active'); const [activeTab, setActiveTab] = useState<'active' | 'history'>('active');
const { activeJob, jobHistory } = useAppSelector((state) => state.job); const { activeJob, jobHistory } = useAppSelector(state => state.job);
const { activeDeliveries } = useAppSelector(state => state.deliveries);
useEffect(() => {
dispatch(getAllActiveDeliveries());
}, [dispatch]);
// Default history mock jobs // Default history mock jobs
const defaultHistory = [ const defaultHistory = [
@ -20,8 +33,8 @@ export const JobsScreen: React.FC = () => {
pickupAddress: 'MG Road, Bengaluru', pickupAddress: 'MG Road, Bengaluru',
dropName: 'Rohit Sharma', dropName: 'Rohit Sharma',
dropAddress: 'Koramangala 4th Block, Bengaluru', dropAddress: 'Koramangala 4th Block, Bengaluru',
amount: 78.0, amount: '78.0',
distance: 1.2, distance: '1.2',
itemCount: 2, itemCount: 2,
paymentType: 'online', paymentType: 'online',
}, },
@ -30,8 +43,8 @@ export const JobsScreen: React.FC = () => {
pickupAddress: 'Indiranagar, Bengaluru', pickupAddress: 'Indiranagar, Bengaluru',
dropName: 'Aishwarya Sen', dropName: 'Aishwarya Sen',
dropAddress: 'Domlur Stage 2, Bengaluru', dropAddress: 'Domlur Stage 2, Bengaluru',
amount: 95.0, amount: '95.0',
distance: 3.8, distance: '3.8',
itemCount: 3, itemCount: 3,
paymentType: 'cod', paymentType: 'cod',
}, },
@ -40,8 +53,8 @@ export const JobsScreen: React.FC = () => {
pickupAddress: 'Koramangala 5th Block', pickupAddress: 'Koramangala 5th Block',
dropName: 'Sameer Khan', dropName: 'Sameer Khan',
dropAddress: 'HSR Layout Sector 3, Bengaluru', dropAddress: 'HSR Layout Sector 3, Bengaluru',
amount: 110.0, amount: '110.0',
distance: 4.5, distance: '4.5',
itemCount: 1, itemCount: 1,
paymentType: 'online', paymentType: 'online',
}, },
@ -60,7 +73,12 @@ export const JobsScreen: React.FC = () => {
activeOpacity={0.8} activeOpacity={0.8}
onPress={() => setActiveTab('active')} onPress={() => setActiveTab('active')}
> >
<Text style={[styles.tabText, activeTab === 'active' && styles.tabTextActive]}> <Text
style={[
styles.tabText,
activeTab === 'active' && styles.tabTextActive,
]}
>
Active / Upcoming Active / Upcoming
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -70,32 +88,49 @@ export const JobsScreen: React.FC = () => {
activeOpacity={0.8} activeOpacity={0.8}
onPress={() => setActiveTab('history')} onPress={() => setActiveTab('history')}
> >
<Text style={[styles.tabText, activeTab === 'history' && styles.tabTextActive]}> <Text
style={[
styles.tabText,
activeTab === 'history' && styles.tabTextActive,
]}
>
History History
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false}> <ScrollView
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
>
{activeTab === 'active' ? ( {activeTab === 'active' ? (
// Active job tab // Active job tab
activeJob ? ( activeDeliveries ? (
<View style={styles.cardWrapper}> <View style={styles.cardWrapper}>
<OrderCard <OrderCard
pickupName={activeJob.pickupName} pickupName={activeDeliveries?.order?.pickupAddress?.city}
pickupAddress={activeJob.pickupAddress} pickupAddress={
dropName={activeJob.dropName} activeDeliveries?.order?.pickupAddress?.addressLine1
dropAddress={activeJob.dropAddress} }
amount={activeJob.amount} dropName={activeDeliveries?.order?.dropAddress?.city}
distance={activeJob.distance} dropAddress={activeDeliveries?.order?.dropAddress?.addressLine1}
itemCount={activeJob.itemCount} amount={activeDeliveries?.order?.totalAmount}
paymentType={activeJob.paymentType} distance={activeDeliveries?.estimatedDistanceKm}
itemCount={activeDeliveries?.order?.orderItems?.length}
paymentType={activeDeliveries?.order?.paymentMethod}
onPress={() =>
navigation.navigate(RouteNames.ArrivedAtStore, {
jobId: activeDeliveries?.orderId,
})
}
/> />
</View> </View>
) : ( ) : (
<View style={styles.emptyStateContainer}> <View style={styles.emptyStateContainer}>
<ClipboardIcon size={48} color={colors.placeholder} /> <ClipboardIcon size={48} color={colors.placeholder} />
<Text style={styles.emptyStateText}>No active deliveries right now</Text> <Text style={styles.emptyStateText}>
No active deliveries right now
</Text>
</View> </View>
) )
) : ( ) : (

View File

@ -0,0 +1,31 @@
import { ActiveOrdersResponse } from '@interfaces';
import { createReducer } from '@reduxjs/toolkit';
import { getAllActiveDeliveries } from './thunk';
export interface ActiveDeliveriesState {
activeDeliveries: ActiveOrdersResponse | null;
loading: boolean;
error: string | null;
}
const initialState: ActiveDeliveriesState = {
activeDeliveries: null,
loading: false,
error: null,
};
export const activeDeliveriesReducer = createReducer(initialState, builder => {
builder
.addCase(getAllActiveDeliveries.pending, state => {
state.loading = true;
})
.addCase(getAllActiveDeliveries.fulfilled, (state, action) => {
state.loading = false;
state.activeDeliveries = action.payload;
// console.log(action.payload);
})
.addCase(getAllActiveDeliveries.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
});

View File

@ -0,0 +1,14 @@
import { getActiveDeliveries } from '@api';
import { createAsyncThunk } from '@reduxjs/toolkit';
export const getAllActiveDeliveries = createAsyncThunk(
'jobs/getAllActiveDeliveries',
async (_, { rejectWithValue }) => {
try {
const response = await getActiveDeliveries();
return response;
} catch (error) {
return rejectWithValue(error);
}
},
);

View File

@ -1,6 +1,8 @@
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import { typography, spacing, borderRadius, shadow } from '@theme'; import { typography, spacing, borderRadius, shadow } from '@theme';
const TIMER_SIZE = 56;
export const getStyles = (colors: any) => export const getStyles = (colors: any) =>
StyleSheet.create({ StyleSheet.create({
container: { container: {
@ -27,21 +29,45 @@ export const getStyles = (colors: any) =>
fontWeight: typography.fontWeight.bold, fontWeight: typography.fontWeight.bold,
color: colors.text, color: colors.text,
}, },
timerContainer: {
width: 44, // ── Circular Timer (border-based) ─────────────────────────────────────
height: 44, timerWrapper: {
borderRadius: 22, width: TIMER_SIZE,
height: TIMER_SIZE,
borderRadius: TIMER_SIZE / 2,
borderWidth: 3, borderWidth: 3,
borderColor: colors.primary,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.primaryLight, backgroundColor: colors.primaryLight,
}, },
timerText: { timerText: {
fontSize: typography.fontSize.sm, fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold, fontWeight: typography.fontWeight.bold,
color: colors.primary,
}, },
// ── Earnings Badge ────────────────────────────────────────────────────
earningsBadge: {
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
alignItems: 'center',
overflow: 'hidden',
},
earningsBadgeLabel: {
fontSize: 10,
fontWeight: 'bold' as const,
textTransform: 'uppercase' as const,
color: 'rgba(255,255,255,0.75)',
marginBottom: 2,
},
earningsBadgeValue: {
fontSize: 28,
fontWeight: typography.fontWeight.bold,
color: '#fff',
},
// ── Route Card ────────────────────────────────────────────────────────
routeContainer: { routeContainer: {
backgroundColor: colors.inputBg, backgroundColor: colors.inputBg,
borderRadius: borderRadius.md, borderRadius: borderRadius.md,
@ -53,6 +79,8 @@ export const getStyles = (colors: any) =>
spacer: { spacer: {
height: spacing.md, height: spacing.md,
}, },
// ── Details Row ───────────────────────────────────────────────────────
detailsRow: { detailsRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -84,6 +112,8 @@ export const getStyles = (colors: any) =>
height: 28, height: 28,
backgroundColor: colors.border, backgroundColor: colors.border,
}, },
// ── Buttons ───────────────────────────────────────────────────────────
btnRow: { btnRow: {
flexDirection: 'row', flexDirection: 'row',
gap: 12, gap: 12,

View File

@ -1,9 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { View, Text, Alert } from 'react-native'; import { View, Text, Animated, Easing } from 'react-native';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { PrimaryButton, LocationRow } from '@components'; import { PrimaryButton, LocationRow } from '@components';
import { useAppDispatch, useAppSelector } from '@store'; import { useAppDispatch, useAppSelector } from '@store';
import { acceptJobThunk, rejectJobThunk } from '@store/commonReducers/job'; import {
respondToOfferThunk,
clearDeliveryOffer,
} from '@store/commonReducers/delivery';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { AppStackParamList } from '@navigation/navigationTypes'; import { AppStackParamList } from '@navigation/navigationTypes';
@ -11,101 +14,188 @@ import { RouteNames } from '@utils/constants';
import { formatCurrency } from '@utils/formatters'; import { formatCurrency } from '@utils/formatters';
import { getStyles } from './newJobRequestScreen.styles'; import { getStyles } from './newJobRequestScreen.styles';
const TOTAL_SECONDS = 15;
export const NewJobRequestScreen: React.FC = () => { export const NewJobRequestScreen: React.FC = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>(); const navigation =
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
const newJobOffer = useAppSelector((state) => state.job.newJobOffer); // Read from the socket-driven delivery slice
const [secondsLeft, setSecondsLeft] = useState(15); const currentOffer = useAppSelector(state => state.delivery.currentOffer);
const respondLoading = useAppSelector(
state => state.delivery.respondLoading,
);
const [secondsLeft, setSecondsLeft] = useState(TOTAL_SECONDS);
// Animated values
const pulseAnim = useRef(new Animated.Value(1)).current;
// ── Countdown timer ──────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (secondsLeft <= 0) { if (secondsLeft <= 0) {
handleReject(); handleReject();
return; return;
} }
const timer = setInterval(() => { const timer = setInterval(() => {
setSecondsLeft((prev) => prev - 1); setSecondsLeft(prev => prev - 1);
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [secondsLeft]); }, [secondsLeft]);
const handleAccept = () => { // ── Pulse animation on Accept button when < 5 s remain ──────────────────
if (newJobOffer?.jobId) { useEffect(() => {
dispatch(acceptJobThunk(newJobOffer.jobId)); if (secondsLeft <= 5 && secondsLeft > 0) {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 400,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 400,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
]),
).start();
} else {
pulseAnim.setValue(1);
} }
Alert.alert('Job Accepted', 'Heading to Cafe Coffee Day pickup location.', [ // eslint-disable-next-line react-hooks/exhaustive-deps
{ }, [secondsLeft <= 5]);
text: 'OK',
onPress: () => navigation.navigate(RouteNames.OrderAccepted), // ── Handlers ─────────────────────────────────────────────────────────────
}, const handleAccept = () => {
]); if (!currentOffer) return;
dispatch(
respondToOfferThunk({
deliveryId: currentOffer.deliveryId,
action: 'ACCEPT',
}),
)
.unwrap()
.then(() => {
navigation.navigate(RouteNames.OrderAccepted);
})
.catch(() => {
// stay on screen — respondError will be set in redux
});
}; };
const handleReject = () => { const handleReject = () => {
if (newJobOffer?.jobId) { if (currentOffer) {
dispatch(rejectJobThunk(newJobOffer.jobId)); dispatch(
respondToOfferThunk({
deliveryId: currentOffer.deliveryId,
action: 'REJECT',
}),
);
} }
navigation.navigate(RouteNames.BottomTabs, { screen: RouteNames.Dashboard }); dispatch(clearDeliveryOffer());
navigation.navigate(RouteNames.BottomTabs, {
screen: RouteNames.Dashboard,
});
}; };
if (!newJobOffer) return null; if (!currentOffer) return null;
// Timer text color: warning when < 5s
const timerColor = secondsLeft <= 5 ? colors.error : colors.primary;
const timerBorderColor =
secondsLeft <= 5 ? colors.error : colors.primary;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.modalContent}> <View style={styles.modalContent}>
{/* Header Row with Title and Countdown */} {/* ── Header Row ─────────────────────────────────────────────────── */}
<View style={styles.headerRow}> <View style={styles.headerRow}>
<Text style={styles.modalTitle}>New Delivery Request</Text> <Text style={styles.modalTitle}>New Delivery Request</Text>
<View style={styles.timerContainer}>
<Text style={styles.timerText}>{secondsLeft}s</Text> {/* Circular countdown timer (pure RN — no SVG needed) */}
<View
style={[
styles.timerWrapper,
{ borderColor: timerBorderColor },
]}
>
<Text style={[styles.timerText, { color: timerColor }]}>
{secondsLeft}s
</Text>
</View> </View>
</View> </View>
{/* Route Details Card */} {/* ── Earnings Badge ─────────────────────────────────────────────── */}
<View
style={[
styles.earningsBadge,
{ backgroundColor: colors.primary },
]}
>
<Text style={styles.earningsBadgeLabel}>Estimated Earnings</Text>
<Text style={styles.earningsBadgeValue}>
{formatCurrency(currentOffer.totalAmount)}
</Text>
</View>
{/* ── Route Details ──────────────────────────────────────────────── */}
<View style={styles.routeContainer}> <View style={styles.routeContainer}>
<LocationRow <LocationRow
type="pickup" type="pickup"
name="Cafe Coffee Day" name="Pickup Location"
address={newJobOffer.pickupAddress} address={currentOffer.pickupAddress.label}
/> />
<View style={styles.spacer} /> <View style={styles.spacer} />
<LocationRow <LocationRow
type="drop" type="drop"
name="Rohit Sharma" name="Drop Location"
address={newJobOffer.dropAddress} address={currentOffer.dropAddress.label}
/> />
</View> </View>
{/* Earnings & Distance Details */} {/* ── Distance & Order ID ────────────────────────────────────────── */}
<View style={styles.detailsRow}> <View style={styles.detailsRow}>
<View style={styles.detailBox}> <View style={styles.detailBox}>
<Text style={styles.detailLabel}>Estimated Earnings</Text> <Text style={styles.detailLabel}>Distance</Text>
<Text style={styles.detailValue}>{formatCurrency(newJobOffer.amount)}</Text> <Text style={styles.detailValue}>
{currentOffer.distanceKm} km
</Text>
</View> </View>
<View style={styles.divider} /> <View style={styles.divider} />
<View style={styles.detailBox}> <View style={styles.detailBox}>
<Text style={styles.detailLabel}>Distance</Text> <Text style={styles.detailLabel}>Order</Text>
<Text style={styles.detailValue}>{newJobOffer.distance} km</Text> <Text style={styles.detailValue}>#{currentOffer.orderId}</Text>
</View> </View>
</View> </View>
{/* Button Actions */} {/* ── Action Buttons ─────────────────────────────────────────────── */}
<View style={styles.btnRow}> <View style={styles.btnRow}>
<PrimaryButton <PrimaryButton
title="Reject" title="Reject"
onPress={handleReject} onPress={handleReject}
style={styles.rejectBtn} style={styles.rejectBtn}
textStyle={{ color: colors.text }} textStyle={{ color: colors.text }}
disabled={respondLoading}
/> />
<Animated.View
style={[{ flex: 2, transform: [{ scale: pulseAnim }] }]}
>
<PrimaryButton <PrimaryButton
title="Accept" title={respondLoading ? 'Accepting...' : 'Accept'}
onPress={handleAccept} onPress={handleAccept}
style={styles.acceptBtn} style={[styles.acceptBtn, { flex: undefined }]}
disabled={respondLoading}
/> />
</Animated.View>
</View> </View>
</View> </View>
</View> </View>

View File

@ -7,3 +7,17 @@ export interface ToggleStatusResponse {
availabilityStatus: string; availabilityStatus: string;
message: string; message: string;
} }
export interface DeliveryPartnerLocationRequest {
latitude: number;
longitude: number;
accuracyMeters: number;
speedKph: number;
heading: number;
}
export interface DeliveryPartnerLocationResponse {
message: string;
latitude: number;
longitude: number;
}

View File

@ -0,0 +1,27 @@
// Socket offer payload (delivery_offer event)
export interface DeliveryOffer {
deliveryId: string;
orderId: string;
pickupAddress: { label: string; lat: number; lng: number };
dropAddress: { label: string; lat: number; lng: number };
totalAmount: number;
distanceKm: number;
}
// REST: POST /delivery-partners/deliveries/{id}/respond
export type DeliveryAction = 'ACCEPT' | 'REJECT';
export interface RespondToOfferPayload {
action: DeliveryAction;
}
export interface RespondToOfferResponse {
success: boolean;
message: string;
deliveryId?: string;
}
// REST: GET /delivery-partners/deliveries/pending-offer
export interface PendingOfferResponse {
offer: DeliveryOffer | null;
}

View File

@ -2,3 +2,5 @@ export * from './auth';
export * from './onboard'; export * from './onboard';
export * from './kyc'; export * from './kyc';
export * from './dashboard'; export * from './dashboard';
export * from './delivery';
export * from './order';

112
app/interfaces/order.ts Normal file
View File

@ -0,0 +1,112 @@
export interface ActiveOrdersResponse {
id: string;
orderId: string;
deliveryPartnerId: string;
deliveryNumber: string;
status: string;
estimatedDistanceKm: string;
actualDistanceKm: string;
proofOfPickupKey: string;
proofOfDeliveryKey: string;
pickedUpAt: string;
deliveredAt: string;
order: {
id: string;
orderNumber: string;
customerId: string;
merchantId: string;
couponCode: string;
appliedPromotionId: string;
status: string;
orderType: string;
paymentMethodId: string;
paymentMethod: string;
currency: string;
subtotal: string;
deliveryFee: string;
platformFee: string;
serviceFee: string;
taxAmount: string;
discountAmount: string;
totalAmount: string;
pickupAddress: {
city: string;
label: string;
phone: string;
state: string;
landmark: string;
latitude: number;
longitude: number;
postalCode: string;
houseNumber: string;
addressLine1: string;
};
dropAddress: {
city: string;
label: string;
phone: string;
state: string;
landmark: string;
latitude: number;
longitude: number;
postalCode: string;
houseNumber: string;
addressLine1: string;
};
// "metadata": {
// "idempotencyKey": "idemp-key-12345",
// "trackingHistory": {
// "CONFIRMED": "2026-07-13T10:21:35.863Z"
// },
// "rejectedDriverIds": [
// "e5f639a8-f0cf-4357-ab45-919ebc3012bd"
// ]
// },
version: 1;
createdAt: string;
updatedAt: string;
deletedAt: string;
customer: {
id: string;
userId: string;
customerCode: string;
createdAt: string;
deletedAt: string;
user: {
id: string;
name: string;
phone: string;
profileImage: string;
};
};
// "merchant": {
// "id": "f53b10bf-1d72-42e6-9d4e-b78032ad051b",
// "name": "eererer",
// "imageUrl": null,
// "addresses": [
// {
// "id": "5476dd01-4b9c-4597-ad0f-1898ad333af6",
// "mapAddress": "Bantra, Jaynagar - I, South 24 Parganas, West Bengal, India",
// "latitude": "22.204311",
// "longitude": "88.509444",
// "city": "New York",
// "landmark": "Near Central Park",
// "phone": "+19999999999"
// }
// ]
// },
orderItems: OrderItem[];
};
}
export interface OrderItem {
id: string;
orderId: string;
productId: string;
itemType: string;
name: string;
quantity: string;
unitPrice: string;
totalAmount: string;
attributes: object;
}

View File

@ -28,14 +28,15 @@ export type AppStackParamList = {
[RouteNames.BottomTabs]: NavigatorScreenParams<BottomTabParamList>; [RouteNames.BottomTabs]: NavigatorScreenParams<BottomTabParamList>;
[RouteNames.NewJobRequest]: undefined; [RouteNames.NewJobRequest]: undefined;
[RouteNames.OrderAccepted]: undefined; [RouteNames.OrderAccepted]: undefined;
[RouteNames.ArrivedAtStore]: undefined; [RouteNames.ArrivedAtStore]: { jobId: string };
[RouteNames.ConfirmPickup]: undefined; [RouteNames.ConfirmPickup]: undefined;
[RouteNames.OrderPickedUp]: undefined; [RouteNames.OrderPickedUp]: undefined;
[RouteNames.LiveTracking]: undefined; [RouteNames.LiveTracking]: undefined;
[RouteNames.ArrivedAtCustomer]: undefined; [RouteNames.ArrivedAtCustomer]: { jobId: string };
[RouteNames.DeliverOrder]: undefined; [RouteNames.DeliverOrder]: undefined;
[RouteNames.DeliveryCompleted]: undefined; [RouteNames.DeliveryCompleted]: undefined;
[RouteNames.Documents]: undefined; [RouteNames.Documents]: undefined;
// [RouteNames.JobDetails]: { jobId: string };
}; };
export type RootStackParamList = { export type RootStackParamList = {

View File

@ -18,10 +18,10 @@ const onBoardingStack: React.FC = () => {
name={RouteNames.SetLocation} name={RouteNames.SetLocation}
component={SetLocationScreen} component={SetLocationScreen}
/> */} /> */}
{/* <Stack.Screen <Stack.Screen
name={RouteNames.CompleteProfile} name={RouteNames.CompleteProfile}
component={CompleteProfileScreen} component={CompleteProfileScreen}
/> */} />
<Stack.Screen name={RouteNames.Kyc} component={KycScreen} /> <Stack.Screen name={RouteNames.Kyc} component={KycScreen} />
<Stack.Screen <Stack.Screen
name={RouteNames.OnboardingComplete} name={RouteNames.OnboardingComplete}

View File

@ -1,8 +1,9 @@
import React from 'react'; import React, { useRef, useEffect } from 'react';
import { import {
NavigationContainer, NavigationContainer,
DefaultTheme, DefaultTheme,
DarkTheme, DarkTheme,
NavigationContainerRef,
} from '@react-navigation/native'; } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useColorScheme } from 'react-native'; import { useColorScheme } from 'react-native';
@ -11,6 +12,7 @@ import AuthStack from './authStack';
import AppStack from './appStack'; import AppStack from './appStack';
import onBoardingStack from './onBoardingStack'; import onBoardingStack from './onBoardingStack';
import { useAppSelector } from '@store'; import { useAppSelector } from '@store';
import { socketService } from '@services';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
@ -23,8 +25,22 @@ const RootNavigator: React.FC = () => {
state => state.auth.user?.status === 'ONBOARDING', state => state.auth.user?.status === 'ONBOARDING',
); );
const navigationRef = useRef<NavigationContainerRef<RootStackParamList>>(null);
// Inject the navigation ref into the socket service so it can navigate
// to NewJobRequest from anywhere when a delivery_offer event arrives.
useEffect(() => {
if (navigationRef.current) {
socketService.bootstrap(
// store is already injected in App.tsx — pass null so it's not overwritten
null as any,
navigationRef.current,
);
}
}, []);
return ( return (
<NavigationContainer theme={theme}> <NavigationContainer ref={navigationRef} theme={theme}>
<Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Navigator screenOptions={{ headerShown: false }}>
{!accessToken ? ( {!accessToken ? (
<Stack.Screen name="Auth" component={AuthStack} /> <Stack.Screen name="Auth" component={AuthStack} />

View File

@ -15,7 +15,7 @@ const STORAGE_KEYS = {
} as const; } as const;
// ─── Config ────────────────────────────────────────────────────────────────── // ─── Config ──────────────────────────────────────────────────────────────────
const BASE_URL = 'https://720b-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL const BASE_URL = 'https://8415-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
// ─── Token Helpers ─────────────────────────────────────────────────────────── // ─── Token Helpers ───────────────────────────────────────────────────────────
export const tokenManager = { export const tokenManager = {

View File

@ -1,2 +1,3 @@
export * from './apiClient'; export * from './apiClient';
export * from './locationService'; export * from './locationService';
export * from './socketService';

View File

@ -7,6 +7,9 @@ import type { GeoPosition, GeoError } from 'react-native-geolocation-service';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface LatLng { export interface LatLng {
heading: number | null;
speed: number | null;
accuracy: number | null;
latitude: number; latitude: number;
longitude: number; longitude: number;
} }
@ -26,6 +29,9 @@ const GOOGLE_MAPS_API_KEY = 'AIzaSyDKaKVyQCmSpHMXcMkNCccSXpILpuvwsVM';
export const DEFAULT_LOCATION: LatLng = { export const DEFAULT_LOCATION: LatLng = {
latitude: 12.9352, latitude: 12.9352,
longitude: 77.6245, longitude: 77.6245,
heading: 0,
speed: 0,
accuracy: 0,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -103,10 +109,15 @@ const getPositionOnce = (highAccuracy: boolean): Promise<LatLng> =>
new Promise<LatLng>((resolve, reject) => { new Promise<LatLng>((resolve, reject) => {
Geolocation.getCurrentPosition( Geolocation.getCurrentPosition(
(position: GeoPosition) => { (position: GeoPosition) => {
// console.log('position', position);
resolve({ resolve({
latitude: position.coords.latitude, latitude: position.coords.latitude,
longitude: position.coords.longitude, longitude: position.coords.longitude,
heading: position.coords.heading,
speed: position.coords.speed,
accuracy: position.coords.accuracy,
}); });
// console.log('-----------------');
}, },
(error: GeoError) => { (error: GeoError) => {
console.log( console.log(
@ -231,8 +242,8 @@ export const getCurrentLocationWithAddress =
const coords = await getCurrentPosition(); const coords = await getCurrentPosition();
const address = await reverseGeocode(coords); const address = await reverseGeocode(coords);
console.log('coords', coords); // console.log('coords', coords);
console.log('address', address); // console.log('address', address);
return { coords, address }; return { coords, address };
}; };

View File

@ -0,0 +1,145 @@
import { io, Socket } from 'socket.io-client';
import { Store } from '@reduxjs/toolkit';
import { SocketEvents } from '@utils/constants';
import { DeliveryOffer } from '@interfaces';
import { setDeliveryOffer } from '@store/commonReducers/delivery';
import { RouteNames } from '@utils/constants';
// Must match the REST API base URL (without trailing slash)
const BASE_URL = 'https://8415-202-8-116-13.ngrok-free.app';
type Callback = (data: any) => void;
/**
* Global singleton socket service powered by socket.io-client.
*
* Usage:
* bootstrapSocketService(store, navRef) // call once in App.tsx
* socketService.connect(token) // when user authenticates
* socketService.disconnect() // on logout
*/
class SocketService {
private socket: Socket | null = null;
private store: Store | null = null;
private navigationRef: any = null;
/**
* Must be called once at app startup so the service can dispatch actions
* and navigate globally when a `delivery_offer` event arrives.
* Each parameter is only updated if a non-null value is provided, allowing
* the store and navigationRef to be injected independently.
*/
bootstrap(store: Store | null, navigationRef: any) {
if (store) { this.store = store; }
if (navigationRef) { this.navigationRef = navigationRef; }
}
/**
* Open the socket connection with JWT auth.
*/
connect(token: string) {
if (this.socket?.connected) {
console.log('[Socket] Already connected');
return;
}
this.socket = io(BASE_URL, {
auth: { token },
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
});
this.socket.on('connect', () => {
console.log('[Socket] Connected — id:', this.socket?.id);
});
this.socket.on('connect_error', (err: Error) => {
console.error('[Socket] Connection error:', err.message);
});
this.socket.on('disconnect', (reason: string) => {
console.log('[Socket] Disconnected:', reason);
});
// ── Listen for delivery offers ─────────────────────────────────────
this.socket.on(
SocketEvents.DeliveryOffer,
(data: DeliveryOffer) => {
console.log('[Socket] delivery_offer received:', data);
if (this.store) {
this.store.dispatch(setDeliveryOffer(data));
}
// Navigate to NewJobRequest screen regardless of current screen
if (this.navigationRef?.isReady()) {
this.navigationRef.navigate(RouteNames.NewJobRequest);
}
},
);
}
/**
* Gracefully close the socket connection.
*/
disconnect() {
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
console.log('[Socket] Disconnected (manual)');
}
}
/**
* Notify the server that the partner is available for deliveries.
*/
goOnline() {
this.socket?.emit(SocketEvents.GoOnline);
console.log('[Socket] Emitted go_online');
}
/**
* Notify the server that the partner is no longer available.
*/
goOffline() {
this.socket?.emit(SocketEvents.GoOffline);
console.log('[Socket] Emitted go_offline');
}
/**
* Send a location update to the server.
*/
updateLocation(coords: { latitude: number; longitude: number }) {
this.socket?.emit(SocketEvents.LocationUpdate, coords);
}
/**
* Register a custom listener on the underlying socket.
*/
on(event: string, callback: Callback) {
this.socket?.on(event, callback);
}
/**
* Remove a custom listener from the underlying socket.
*/
off(event: string, callback?: Callback) {
if (callback) {
this.socket?.off(event, callback);
} else {
this.socket?.removeAllListeners(event);
}
}
/**
* Whether the socket is currently connected.
*/
get connected(): boolean {
return this.socket?.connected ?? false;
}
}
export const socketService = new SocketService();

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducer';

View File

@ -0,0 +1,61 @@
import { createReducer, createAction } from '@reduxjs/toolkit';
import { DeliveryOffer } from '@interfaces';
import { respondToOfferThunk, checkPendingOfferThunk } from './thunk';
export interface DeliveryState {
currentOffer: DeliveryOffer | null;
respondLoading: boolean;
respondError: string | null;
pendingOfferLoading: boolean;
}
const initialState: DeliveryState = {
currentOffer: null,
respondLoading: false,
respondError: null,
pendingOfferLoading: false,
};
/** Dispatched by socketService when a `delivery_offer` event is received. */
export const setDeliveryOffer = createAction<DeliveryOffer>(
'delivery/setDeliveryOffer',
);
/** Dispatched after accept/reject to clear the current offer from state. */
export const clearDeliveryOffer = createAction('delivery/clearDeliveryOffer');
export const deliveryReducer = createReducer(initialState, builder => {
builder
// ── Sync actions ──────────────────────────────────────────────────────
.addCase(setDeliveryOffer, (state, action) => {
state.currentOffer = action.payload;
})
.addCase(clearDeliveryOffer, state => {
state.currentOffer = null;
state.respondError = null;
})
// ── respondToOfferThunk ───────────────────────────────────────────────
.addCase(respondToOfferThunk.pending, state => {
state.respondLoading = true;
state.respondError = null;
})
.addCase(respondToOfferThunk.fulfilled, state => {
state.respondLoading = false;
state.currentOffer = null;
})
.addCase(respondToOfferThunk.rejected, (state, action) => {
state.respondLoading = false;
state.respondError = action.payload as string;
})
// ── checkPendingOfferThunk ────────────────────────────────────────────
.addCase(checkPendingOfferThunk.pending, state => {
state.pendingOfferLoading = true;
})
.addCase(checkPendingOfferThunk.fulfilled, (state, action) => {
state.pendingOfferLoading = false;
state.currentOffer = action.payload.offer;
})
.addCase(checkPendingOfferThunk.rejected, state => {
state.pendingOfferLoading = false;
});
});

View File

@ -0,0 +1,42 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import {
DeliveryAction,
RespondToOfferResponse,
PendingOfferResponse,
} from '@interfaces';
import * as deliveryApi from '../../../api/deliveryApi';
/**
* Respond to a delivery offer (ACCEPT / REJECT).
*/
export const respondToOfferThunk = createAsyncThunk<
RespondToOfferResponse,
{ deliveryId: string; action: DeliveryAction }
>(
'delivery/respondToOffer',
async ({ deliveryId, action }, { rejectWithValue }) => {
try {
return await deliveryApi.respondToOffer(deliveryId, action);
} catch (error: any) {
return rejectWithValue(
error.message || 'Failed to respond to delivery offer',
);
}
},
);
/**
* Check for any pending delivery offer (e.g. on app launch / reconnect).
*/
export const checkPendingOfferThunk = createAsyncThunk<PendingOfferResponse>(
'delivery/checkPendingOffer',
async (_, { rejectWithValue }) => {
try {
return await deliveryApi.getPendingOffer();
} catch (error: any) {
return rejectWithValue(
error.message || 'Failed to check pending offers',
);
}
},
);

View File

@ -1,3 +1,4 @@
export * from './auth'; export * from './auth';
export * from './earnings'; export * from './earnings';
export * from './job'; export * from './job';
export * from './delivery';

View File

@ -2,20 +2,24 @@ import { combineReducers } from '@reduxjs/toolkit';
import { authReducer } from './commonReducers/auth'; import { authReducer } from './commonReducers/auth';
import { earningsReducer } from './commonReducers/earnings'; import { earningsReducer } from './commonReducers/earnings';
import { jobReducer } from './commonReducers/job'; import { jobReducer } from './commonReducers/job';
import { deliveryReducer } from './commonReducers/delivery';
import setLocationReducer from '@features/screens/setLocationScreen/reducer'; import setLocationReducer from '@features/screens/setLocationScreen/reducer';
import { completeProfileReducer, kycReducer } from '@features/screens'; import { completeProfileReducer, kycReducer } from '@features/screens';
import { onBoardingCompleteReducer } from '@features/screens/onboardingCompleteScreen'; import { onBoardingCompleteReducer } from '@features/screens/onboardingCompleteScreen';
import { dashBoardReducer } from '@features/screens/dashboardScreen'; import { dashBoardReducer } from '@features/screens/dashboardScreen';
import { activeDeliveriesReducer } from '@features/screens/jobsScreen';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
auth: authReducer, auth: authReducer,
earnings: earningsReducer, earnings: earningsReducer,
job: jobReducer, job: jobReducer,
delivery: deliveryReducer,
setLocation: setLocationReducer, setLocation: setLocationReducer,
onboard: completeProfileReducer, onboard: completeProfileReducer,
kyc: kycReducer, kyc: kycReducer,
onboardingComplete: onBoardingCompleteReducer, onboardingComplete: onBoardingCompleteReducer,
dashboard: dashBoardReducer, dashboard: dashBoardReducer,
deliveries: activeDeliveriesReducer,
}); });
export type RootState = ReturnType<typeof rootReducer>; export type RootState = ReturnType<typeof rootReducer>;

View File

@ -36,8 +36,8 @@ export interface JobOffer {
dropName: string; dropName: string;
dropAddress: string; dropAddress: string;
dropCoordinates: Coordinates; dropCoordinates: Coordinates;
amount: number; amount: string;
distance: number; distance: string;
estimatedTime: number; estimatedTime: number;
paymentType: 'online' | 'cod'; paymentType: 'online' | 'cod';
itemCount: number; itemCount: number;

View File

@ -45,6 +45,7 @@ export enum JobStatus {
export enum SocketEvents { export enum SocketEvents {
NewJob = 'new_job', NewJob = 'new_job',
DeliveryOffer = 'delivery_offer',
JobCancelled = 'job_cancelled', JobCancelled = 'job_cancelled',
LocationUpdate = 'location_update', LocationUpdate = 'location_update',
GoOnline = 'go_online', GoOnline = 'go_online',

511
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -34,7 +34,8 @@
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"reactotron-react-native": "^5.2.0", "reactotron-react-native": "^5.2.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-thunk": "^3.1.0" "redux-thunk": "^3.1.0",
"socket.io-client": "^4.8.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

2120
yarn.lock

File diff suppressed because it is too large Load Diff