feat: implement core delivery partner dashboard with live location tracking, socket connectivity, and availability status toggling
This commit is contained in:
parent
a6c713ae8c
commit
98e28742de
27
app/App.tsx
27
app/App.tsx
@ -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
|
||||||
|
|||||||
@ -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
40
app/api/deliveryApi.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -1,2 +1,4 @@
|
|||||||
export { default } from './jobsScreen';
|
export { default } from './jobsScreen';
|
||||||
export * from './jobsScreen';
|
export * from './jobsScreen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducer';
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
31
app/features/screens/jobsScreen/reducer.ts
Normal file
31
app/features/screens/jobsScreen/reducer.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
14
app/features/screens/jobsScreen/thunk.ts
Normal file
14
app/features/screens/jobsScreen/thunk.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
<PrimaryButton
|
<Animated.View
|
||||||
title="Accept"
|
style={[{ flex: 2, transform: [{ scale: pulseAnim }] }]}
|
||||||
onPress={handleAccept}
|
>
|
||||||
style={styles.acceptBtn}
|
<PrimaryButton
|
||||||
/>
|
title={respondLoading ? 'Accepting...' : 'Accept'}
|
||||||
|
onPress={handleAccept}
|
||||||
|
style={[styles.acceptBtn, { flex: undefined }]}
|
||||||
|
disabled={respondLoading}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
27
app/interfaces/delivery.ts
Normal file
27
app/interfaces/delivery.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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
112
app/interfaces/order.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from './apiClient';
|
export * from './apiClient';
|
||||||
export * from './locationService';
|
export * from './locationService';
|
||||||
|
export * from './socketService';
|
||||||
|
|||||||
@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
145
app/services/socketService.ts
Normal file
145
app/services/socketService.ts
Normal 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();
|
||||||
2
app/store/commonReducers/delivery/index.ts
Normal file
2
app/store/commonReducers/delivery/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './thunk';
|
||||||
|
export * from './reducer';
|
||||||
61
app/store/commonReducers/delivery/reducer.ts
Normal file
61
app/store/commonReducers/delivery/reducer.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
42
app/store/commonReducers/delivery/thunk.ts
Normal file
42
app/store/commonReducers/delivery/thunk.ts
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
511
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user