feat: implement real-time delivery dispatch flow with socket integration, status tracking, and UI screens
This commit is contained in:
parent
55bab67d91
commit
3e18e618d1
@ -4,7 +4,7 @@ import { DeliveryPartnerProfileResponse } from '@interfaces';
|
||||
export const getAccountInfo =
|
||||
async (): Promise<DeliveryPartnerProfileResponse> => {
|
||||
const response = await apiClient.get<DeliveryPartnerProfileResponse>(
|
||||
'/account-info',
|
||||
'/delivery-partners/profile',
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from 'react-native-maps';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { Coordinates } from '@app-types/index';
|
||||
import { getStyles } from './mapView.styles';
|
||||
import {
|
||||
getRouteDirections,
|
||||
getHaversineDistance,
|
||||
} from '@services/locationService';
|
||||
|
||||
interface MapViewComponentProps {
|
||||
origin?: Coordinates;
|
||||
@ -22,6 +26,9 @@ export const MapViewComponent: React.FC<MapViewComponentProps> = ({
|
||||
}) => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
const [routeCoords, setRouteCoords] = useState<Coordinates[]>([]);
|
||||
const lastFetchedOrigin = useRef<Coordinates | null>(null);
|
||||
|
||||
const defaultRegion = {
|
||||
latitude: currentLocation?.latitude || origin?.latitude || 12.9716,
|
||||
@ -30,9 +37,83 @@ export const MapViewComponent: React.FC<MapViewComponentProps> = ({
|
||||
longitudeDelta: 0.05,
|
||||
};
|
||||
|
||||
// Fetch routing coordinates
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (showRoute && origin && destination) {
|
||||
// Calculate distance from last fetched origin
|
||||
if (lastFetchedOrigin.current) {
|
||||
const dist = getHaversineDistance(origin, lastFetchedOrigin.current);
|
||||
// If driver moved less than 50 meters (0.05 km), skip re-fetching to prevent spamming
|
||||
if (dist < 0.05 && routeCoords.length > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getRouteDirections(origin, destination)
|
||||
.then(points => {
|
||||
if (active) {
|
||||
setRouteCoords(points);
|
||||
lastFetchedOrigin.current = origin;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error in MapViewComponent getRouteDirections:', err);
|
||||
if (active) {
|
||||
setRouteCoords([origin, destination]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setRouteCoords([]);
|
||||
lastFetchedOrigin.current = null;
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
showRoute,
|
||||
origin?.latitude,
|
||||
origin?.longitude,
|
||||
destination?.latitude,
|
||||
destination?.longitude,
|
||||
routeCoords.length,
|
||||
]);
|
||||
|
||||
// Adjust camera to fit all coordinates
|
||||
useEffect(() => {
|
||||
const coordsToFit: Coordinates[] = [];
|
||||
if (currentLocation) {
|
||||
coordsToFit.push(currentLocation);
|
||||
}
|
||||
if (origin) {
|
||||
coordsToFit.push(origin);
|
||||
}
|
||||
if (destination) {
|
||||
coordsToFit.push(destination);
|
||||
}
|
||||
|
||||
if (coordsToFit.length > 0 && mapRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
mapRef.current?.fitToCoordinates(coordsToFit, {
|
||||
edgePadding: { top: 60, right: 60, bottom: 60, left: 60 },
|
||||
animated: true,
|
||||
});
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [
|
||||
currentLocation?.latitude,
|
||||
currentLocation?.longitude,
|
||||
origin?.latitude,
|
||||
origin?.longitude,
|
||||
destination?.latitude,
|
||||
destination?.longitude,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
style={styles.map}
|
||||
initialRegion={defaultRegion}
|
||||
@ -60,9 +141,9 @@ export const MapViewComponent: React.FC<MapViewComponentProps> = ({
|
||||
pinColor={colors.statusOnline}
|
||||
/>
|
||||
)}
|
||||
{showRoute && origin && destination && (
|
||||
{showRoute && routeCoords.length > 0 && (
|
||||
<Polyline
|
||||
coordinates={[origin, destination]}
|
||||
coordinates={routeCoords}
|
||||
strokeColor={colors.primary}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
@ -74,4 +155,6 @@ export const MapViewComponent: React.FC<MapViewComponentProps> = ({
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapViewComponent;
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
showPermissionDeniedAlert,
|
||||
socketService,
|
||||
} from '@services';
|
||||
import { getAccountInfoThunk } from '../profileScreen';
|
||||
|
||||
export const DashboardScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
@ -38,14 +39,14 @@ export const DashboardScreen: React.FC = () => {
|
||||
const { availabiltyStatus, loading: toggleLoading } = useAppSelector(
|
||||
state => state.dashboard,
|
||||
);
|
||||
const { user } = useAppSelector(state => state.auth);
|
||||
const { profile } = useAppSelector(state => state.accountInfo);
|
||||
const authIsOnline = useAppSelector(state => state.auth.isOnline);
|
||||
const isOnline = availabiltyStatus
|
||||
? availabiltyStatus.availabilityStatus === 'ONLINE'
|
||||
: authIsOnline;
|
||||
const { todayEarnings, completedCount, onlineMinutes, weeklyData } =
|
||||
useAppSelector(state => state.earnings);
|
||||
const { jobStatus, activeJob } = useAppSelector(state => state.job);
|
||||
// const { jobStatus, activeJob } = useAppSelector(state => state.job);
|
||||
|
||||
const locationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
@ -70,6 +71,10 @@ export const DashboardScreen: React.FC = () => {
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getAccountInfoThunk());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnline) {
|
||||
// Fire one immediately on going online, then every 15s
|
||||
@ -122,31 +127,31 @@ export const DashboardScreen: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleActiveJobBannerPress = () => {
|
||||
// Navigate back to the screen corresponding to the current job step
|
||||
switch (jobStatus) {
|
||||
case JobStatus.NewRequest:
|
||||
navigation.navigate(RouteNames.NewJobRequest);
|
||||
break;
|
||||
case JobStatus.Accepted:
|
||||
navigation.navigate(RouteNames.OrderAccepted);
|
||||
break;
|
||||
case JobStatus.ArrivedAtStore:
|
||||
navigation.navigate(RouteNames.ConfirmPickup);
|
||||
break;
|
||||
case JobStatus.PickedUp:
|
||||
navigation.navigate(RouteNames.OrderPickedUp);
|
||||
break;
|
||||
case JobStatus.EnRoute:
|
||||
navigation.navigate(RouteNames.LiveTracking);
|
||||
break;
|
||||
case JobStatus.ArrivedAtCustomer:
|
||||
navigation.navigate(RouteNames.DeliverOrder);
|
||||
break;
|
||||
default:
|
||||
Alert.alert('Status Error', 'Unknown active delivery state.');
|
||||
}
|
||||
};
|
||||
// const handleActiveJobBannerPress = () => {
|
||||
// // Navigate back to the screen corresponding to the current job step
|
||||
// switch (jobStatus) {
|
||||
// case JobStatus.NewRequest:
|
||||
// navigation.navigate(RouteNames.NewJobRequest);
|
||||
// break;
|
||||
// case JobStatus.Accepted:
|
||||
// navigation.navigate(RouteNames.OrderAccepted);
|
||||
// break;
|
||||
// case JobStatus.ArrivedAtStore:
|
||||
// navigation.navigate(RouteNames.ConfirmPickup);
|
||||
// break;
|
||||
// case JobStatus.PickedUp:
|
||||
// navigation.navigate(RouteNames.OrderPickedUp);
|
||||
// break;
|
||||
// case JobStatus.EnRoute:
|
||||
// navigation.navigate(RouteNames.LiveTracking);
|
||||
// break;
|
||||
// case JobStatus.ArrivedAtCustomer:
|
||||
// navigation.navigate(RouteNames.DeliverOrder);
|
||||
// break;
|
||||
// default:
|
||||
// Alert.alert('Status Error', 'Unknown active delivery state.');
|
||||
// }
|
||||
// };
|
||||
|
||||
const formatOnlineTime = (mins: number) => {
|
||||
const hours = Math.floor(mins / 60);
|
||||
@ -169,7 +174,7 @@ export const DashboardScreen: React.FC = () => {
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.greetingText}>Good Morning ☀️</Text>
|
||||
<Text style={styles.nameText}>{user?.name}</Text>
|
||||
<Text style={styles.nameText}>{profile?.user?.name}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.toggleWrapper}>
|
||||
@ -371,7 +376,7 @@ export const DashboardScreen: React.FC = () => {
|
||||
</ScrollView>
|
||||
|
||||
{/* Active Job Floating Bottom Banner */}
|
||||
{jobStatus !== JobStatus.Idle &&
|
||||
{/* {jobStatus !== JobStatus.Idle &&
|
||||
jobStatus !== JobStatus.NewRequest &&
|
||||
activeJob && (
|
||||
<TouchableOpacity
|
||||
@ -445,7 +450,7 @@ export const DashboardScreen: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
)} */}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ export const updateDeliveryPartnerLocation = createAsyncThunk(
|
||||
const response = await deliveryPartnerLocation(payload);
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
return rejectWithValue(error);
|
||||
}
|
||||
},
|
||||
|
||||
@ -10,6 +10,7 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AppStackParamList } from '@navigation/navigationTypes';
|
||||
import { RouteNames } from '@utils/constants';
|
||||
import { DeliveryStatus } from '@interfaces';
|
||||
|
||||
export const JobsScreen: React.FC = () => {
|
||||
const navigation =
|
||||
@ -133,11 +134,23 @@ export const JobsScreen: React.FC = () => {
|
||||
activeDeliveries?.delivery?.order?.orderItems?.length
|
||||
}
|
||||
paymentType={activeDeliveries?.delivery?.order?.paymentMethod}
|
||||
onPress={() =>
|
||||
navigation.navigate(RouteNames.OrderPickedUp, {
|
||||
jobId: activeDeliveries?.delivery?.id,
|
||||
})
|
||||
onPress={() => {
|
||||
const deliveryId = activeDeliveries?.delivery?.id;
|
||||
const status = activeDeliveries?.delivery?.status as DeliveryStatus | undefined;
|
||||
|
||||
// Statuses that mean delivery has already started — skip OrderPickedUp
|
||||
const deliveryStartedStatuses: DeliveryStatus[] = [
|
||||
'OUT_FOR_DELIVERY',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED_AT_DROPOFF',
|
||||
];
|
||||
|
||||
if (status && deliveryStartedStatuses.includes(status)) {
|
||||
navigation.navigate(RouteNames.LiveTracking, { jobId: deliveryId });
|
||||
} else {
|
||||
navigation.navigate(RouteNames.OrderPickedUp, { jobId: deliveryId });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Alert } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, Alert, BackHandler } from 'react-native';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { PrimaryButton } from '@components';
|
||||
import { PrimaryButton, MapViewComponent } from '@components';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { advanceJobStatus } from '@store/commonReducers/job';
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
@ -9,6 +9,15 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AppStackParamList } from '@navigation/navigationTypes';
|
||||
import { RouteNames, JobStatus } from '@utils/constants';
|
||||
import { getStyles } from './liveTrackingScreen.styles';
|
||||
import { Coordinates } from '@app-types/index';
|
||||
import {
|
||||
requestLocationPermission,
|
||||
showPermissionDeniedAlert,
|
||||
getCurrentPosition,
|
||||
getHaversineDistance,
|
||||
} from '@services/locationService';
|
||||
import { socketService } from '@services/socketService';
|
||||
import Geolocation from 'react-native-geolocation-service';
|
||||
|
||||
export const LiveTrackingScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
@ -17,12 +26,102 @@ export const LiveTrackingScreen: React.FC = () => {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
const route =
|
||||
useRoute<RouteProp<AppStackParamList, RouteNames.OrderPickedUp>>();
|
||||
useRoute<RouteProp<AppStackParamList, RouteNames.LiveTracking>>();
|
||||
const { jobId } = route.params;
|
||||
|
||||
const { activeDeliveries } = useAppSelector(state => state.deliveries);
|
||||
const activeJob = activeDeliveries?.delivery;
|
||||
|
||||
const [currentLocation, setCurrentLocation] = useState<Coordinates | null>(
|
||||
null,
|
||||
);
|
||||
const [distance, setDistance] = useState<number | null>(null);
|
||||
const [speed, setSpeed] = useState<number>(24); // default speed in km/h
|
||||
|
||||
// Block the hardware back button — once delivery starts the partner
|
||||
// must not navigate back to the OrderPickedUp screen.
|
||||
// useEffect(() => {
|
||||
// const onBackPress = () => true; // return true = event consumed, back suppressed
|
||||
// const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
||||
// return () => subscription.remove();
|
||||
// }, []);
|
||||
|
||||
useEffect(() => {
|
||||
let watchId: number | null = null;
|
||||
|
||||
const startTracking = async () => {
|
||||
try {
|
||||
const hasPermission = await requestLocationPermission();
|
||||
if (!hasPermission) {
|
||||
showPermissionDeniedAlert();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get initial current position
|
||||
const initialPos = await getCurrentPosition();
|
||||
setCurrentLocation(initialPos);
|
||||
|
||||
// Send initial location update to socket
|
||||
socketService.updateLocation(initialPos);
|
||||
|
||||
// Start watching position
|
||||
watchId = Geolocation.watchPosition(
|
||||
position => {
|
||||
const coords: Coordinates = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
};
|
||||
setCurrentLocation(coords);
|
||||
|
||||
// Emit location update via socket safely
|
||||
try {
|
||||
socketService.updateLocation(coords);
|
||||
} catch (err) {
|
||||
console.warn('Socket location update error:', err);
|
||||
}
|
||||
|
||||
// Update speed if available (speed in m/s from GPS)
|
||||
if (position.coords.speed !== null && position.coords.speed > 0) {
|
||||
const speedKmH = position.coords.speed * 3.6;
|
||||
setSpeed(Math.max(speedKmH, 10)); // keep speed at least 10 km/h for a realistic ETA
|
||||
}
|
||||
},
|
||||
error => {
|
||||
console.error('watchPosition error:', error);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
distanceFilter: 10, // every 10 meters
|
||||
interval: 5000,
|
||||
fastestInterval: 2000,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error starting location tracking:', error);
|
||||
}
|
||||
};
|
||||
|
||||
startTracking();
|
||||
|
||||
return () => {
|
||||
if (watchId !== null) {
|
||||
Geolocation.clearWatch(watchId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Compute live distance to drop-off address
|
||||
useEffect(() => {
|
||||
if (currentLocation && activeJob?.order?.dropAddress) {
|
||||
const dropCoords = {
|
||||
latitude: activeJob.order.dropAddress.latitude,
|
||||
longitude: activeJob.order.dropAddress.longitude,
|
||||
};
|
||||
const km = getHaversineDistance(currentLocation, dropCoords);
|
||||
setDistance(km);
|
||||
}
|
||||
}, [currentLocation, activeJob?.order?.dropAddress]);
|
||||
|
||||
const handleArrived = () => {
|
||||
dispatch(advanceJobStatus(JobStatus.ArrivedAtCustomer));
|
||||
navigation.navigate(RouteNames.DeliverOrder, {
|
||||
@ -39,22 +138,39 @@ export const LiveTrackingScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// if (!activeJob) return null;
|
||||
// Calculate ETA in minutes based on distance and speed (distance / speed * 60)
|
||||
const etaMinutes =
|
||||
distance !== null ? Math.max(Math.round((distance / speed) * 60), 1) : null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Top Floating ETA Card */}
|
||||
<View style={styles.etaCard}>
|
||||
<Text style={styles.etaTitle}>Arriving in 8 mins</Text>
|
||||
<Text style={styles.etaTitle}>
|
||||
{etaMinutes !== null
|
||||
? `Arriving in ${etaMinutes} mins`
|
||||
: 'Calculating ETA…'}
|
||||
</Text>
|
||||
<Text style={styles.etaSub}>
|
||||
Distance: {activeJob?.estimatedDistanceKm} km • Speed: 24 km/h
|
||||
Distance: {distance !== null ? `${distance.toFixed(2)} km` : '...'} •
|
||||
Speed: {Math.round(speed)} km/h
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Map Background Placeholder */}
|
||||
{/* Map Content */}
|
||||
{activeJob?.order?.dropAddress ? (
|
||||
<MapViewComponent
|
||||
currentLocation={currentLocation || undefined}
|
||||
origin={currentLocation || undefined}
|
||||
destination={activeJob.order.dropAddress}
|
||||
showRoute={!!currentLocation}
|
||||
style={styles.mapPlaceholder}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.placeholderText}>[ Map Placeholder ]</Text>
|
||||
<Text style={styles.placeholderText}>Loading Map...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Customer Location Bottom Card */}
|
||||
<View style={styles.bottomCard}>
|
||||
@ -63,9 +179,9 @@ export const LiveTrackingScreen: React.FC = () => {
|
||||
{activeJob?.order?.customer?.user?.name}
|
||||
</Text>
|
||||
<Text style={styles.customerAddress}>
|
||||
{activeJob?.order?.dropAddress?.houseNumber +
|
||||
', ' +
|
||||
activeJob?.order?.dropAddress?.addressLine1}
|
||||
{activeJob?.order?.dropAddress
|
||||
? `${activeJob.order.dropAddress.houseNumber}, ${activeJob.order.dropAddress.addressLine1}`
|
||||
: 'Loading address...'}
|
||||
</Text>
|
||||
|
||||
<View style={styles.btnRow}>
|
||||
|
||||
@ -25,9 +25,7 @@ export const NewJobRequestScreen: React.FC = () => {
|
||||
|
||||
// Read from the socket-driven delivery slice
|
||||
const currentOffer = useAppSelector(state => state.delivery.currentOffer);
|
||||
const respondLoading = useAppSelector(
|
||||
state => state.delivery.respondLoading,
|
||||
);
|
||||
const respondLoading = useAppSelector(state => state.delivery.respondLoading);
|
||||
|
||||
const [secondsLeft, setSecondsLeft] = useState(TOTAL_SECONDS);
|
||||
|
||||
@ -83,7 +81,9 @@ export const NewJobRequestScreen: React.FC = () => {
|
||||
)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
navigation.navigate(RouteNames.OrderAccepted);
|
||||
navigation.navigate(RouteNames.BottomTabs, {
|
||||
screen: RouteNames.Jobs,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// stay on screen — respondError will be set in redux
|
||||
@ -109,8 +109,7 @@ export const NewJobRequestScreen: React.FC = () => {
|
||||
|
||||
// Timer text color: warning when < 5s
|
||||
const timerColor = secondsLeft <= 5 ? colors.error : colors.primary;
|
||||
const timerBorderColor =
|
||||
secondsLeft <= 5 ? colors.error : colors.primary;
|
||||
const timerBorderColor = secondsLeft <= 5 ? colors.error : colors.primary;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@ -121,10 +120,7 @@ export const NewJobRequestScreen: React.FC = () => {
|
||||
|
||||
{/* Circular countdown timer (pure RN — no SVG needed) */}
|
||||
<View
|
||||
style={[
|
||||
styles.timerWrapper,
|
||||
{ borderColor: timerBorderColor },
|
||||
]}
|
||||
style={[styles.timerWrapper, { borderColor: timerBorderColor }]}
|
||||
>
|
||||
<Text style={[styles.timerText, { color: timerColor }]}>
|
||||
{secondsLeft}s
|
||||
@ -134,10 +130,7 @@ export const NewJobRequestScreen: React.FC = () => {
|
||||
|
||||
{/* ── Earnings Badge ─────────────────────────────────────────────── */}
|
||||
<View
|
||||
style={[
|
||||
styles.earningsBadge,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
style={[styles.earningsBadge, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
<Text style={styles.earningsBadgeLabel}>Estimated Earnings</Text>
|
||||
<Text style={styles.earningsBadgeValue}>
|
||||
@ -164,9 +157,7 @@ export const NewJobRequestScreen: React.FC = () => {
|
||||
<View style={styles.detailsRow}>
|
||||
<View style={styles.detailBox}>
|
||||
<Text style={styles.detailLabel}>Distance</Text>
|
||||
<Text style={styles.detailValue}>
|
||||
{currentOffer.distanceKm} km
|
||||
</Text>
|
||||
<Text style={styles.detailValue}>{currentOffer.distanceKm} km</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { PrimaryButton } from '@components';
|
||||
import { PrimaryButton, MapViewComponent } from '@components';
|
||||
import { changeOrderStatusThunk, useAppDispatch, useAppSelector } from '@store';
|
||||
import { advanceJobStatus } from '@store/commonReducers/job';
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
@ -25,23 +25,40 @@ export const OrderPickedUpScreen: React.FC = () => {
|
||||
const { activeDeliveries } = useAppSelector(state => state.deliveries);
|
||||
const activeJob = activeDeliveries?.delivery;
|
||||
|
||||
const handleStartDelivery = () => {
|
||||
dispatch(
|
||||
const handleStartDelivery = async () => {
|
||||
await dispatch(
|
||||
changeOrderStatusThunk({ deliveryId: jobId, action: JobStatus.PickedUp }),
|
||||
);
|
||||
navigation.navigate(RouteNames.LiveTracking, {
|
||||
)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
// Use replace so OrderPickedUp is removed from the stack —
|
||||
// the user cannot come back to it via the back button.
|
||||
navigation.replace(RouteNames.LiveTracking, {
|
||||
jobId: jobId,
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err, 'err');
|
||||
});
|
||||
};
|
||||
|
||||
if (!activeJob) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Map Background Placeholder */}
|
||||
{/* Map Routing Path */}
|
||||
{activeJob?.order?.pickupAddress && activeJob?.order?.dropAddress ? (
|
||||
<MapViewComponent
|
||||
origin={activeJob.order.pickupAddress}
|
||||
destination={activeJob.order.dropAddress}
|
||||
showRoute={true}
|
||||
style={styles.mapPlaceholder}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.placeholderText}>[ Map Placeholder ]</Text>
|
||||
<Text style={styles.placeholderText}>Loading Map...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Deliver Bottom Card */}
|
||||
<View style={styles.bottomCard}>
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { PersonIcon, ChevronRightIcon, ClipboardIcon, StarIcon } from '@icons';
|
||||
import { useAppDispatch } from '@store';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { logout } from '@store/commonReducers/auth';
|
||||
import { useNavigation, CommonActions } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@ -14,15 +14,42 @@ export const ProfileScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<AppStackParamList>>();
|
||||
const { profile } = useAppSelector(state => state.accountInfo);
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'personal', title: 'Personal Info', icon: <PersonIcon size={20} color={colors.primary} /> },
|
||||
{ id: 'vehicle', title: 'Vehicle Info', icon: <ClipboardIcon size={20} color={colors.primary} /> },
|
||||
{ id: 'documents', title: 'Documents', icon: <ClipboardIcon size={20} color={colors.primary} />, route: RouteNames.Documents },
|
||||
{ id: 'bank', title: 'Bank Details', icon: <ClipboardIcon size={20} color={colors.primary} /> },
|
||||
{ id: 'emergency', title: 'Emergency Contact', icon: <PersonIcon size={20} color={colors.primary} /> },
|
||||
{ id: 'settings', title: 'App Settings', icon: <PersonIcon size={20} color={colors.primary} /> },
|
||||
{
|
||||
id: 'personal',
|
||||
title: 'Personal Info',
|
||||
icon: <PersonIcon size={20} color={colors.primary} />,
|
||||
},
|
||||
{
|
||||
id: 'vehicle',
|
||||
title: 'Vehicle Info',
|
||||
icon: <ClipboardIcon size={20} color={colors.primary} />,
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
title: 'Documents',
|
||||
icon: <ClipboardIcon size={20} color={colors.primary} />,
|
||||
route: RouteNames.Documents,
|
||||
},
|
||||
{
|
||||
id: 'bank',
|
||||
title: 'Bank Details',
|
||||
icon: <ClipboardIcon size={20} color={colors.primary} />,
|
||||
},
|
||||
{
|
||||
id: 'emergency',
|
||||
title: 'Emergency Contact',
|
||||
icon: <PersonIcon size={20} color={colors.primary} />,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'App Settings',
|
||||
icon: <PersonIcon size={20} color={colors.primary} />,
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuPress = (item: any) => {
|
||||
@ -49,32 +76,35 @@ export const ProfileScreen: React.FC = () => {
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Auth' }],
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header User Card */}
|
||||
<View style={styles.headerCard}>
|
||||
<View style={styles.avatar}>
|
||||
<PersonIcon size={44} color={colors.primary} />
|
||||
</View>
|
||||
|
||||
<Text style={styles.name}>Rahul Kumar</Text>
|
||||
<Text style={styles.name}>{profile?.user?.name}</Text>
|
||||
|
||||
<View style={styles.ratingRow}>
|
||||
<StarIcon size={16} color={colors.warning} />
|
||||
<Text style={styles.ratingText}>4.8</Text>
|
||||
<Text style={styles.tripsText}>(128 Trips)</Text>
|
||||
<Text style={styles.ratingText}>{profile?.rating}</Text>
|
||||
<Text style={styles.tripsText}>{profile?.user?.email}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.idText}>ID: DP126478</Text>
|
||||
<Text style={styles.idText}>ID: {profile?.user?.id}</Text>
|
||||
</View>
|
||||
|
||||
{/* Profile Menu options */}
|
||||
@ -82,7 +112,10 @@ export const ProfileScreen: React.FC = () => {
|
||||
{menuItems.map((item, idx) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[styles.menuItem, idx === menuItems.length - 1 && { borderBottomWidth: 0 }]}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
idx === menuItems.length - 1 && { borderBottomWidth: 0 },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => handleMenuPress(item)}
|
||||
>
|
||||
@ -96,7 +129,11 @@ export const ProfileScreen: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Logout Button */}
|
||||
<TouchableOpacity style={styles.logoutBtn} activeOpacity={0.8} onPress={handleLogout}>
|
||||
<TouchableOpacity
|
||||
style={styles.logoutBtn}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<Text style={styles.logoutText}>Log Out</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
||||
@ -21,16 +21,46 @@ const Stack = createNativeStackNavigator<AppStackParamList>();
|
||||
const AppStack: React.FC = () => {
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name={RouteNames.BottomTabs} component={BottomTabNavigator} />
|
||||
<Stack.Screen name={RouteNames.NewJobRequest} component={NewJobRequestScreen} />
|
||||
<Stack.Screen name={RouteNames.OrderAccepted} component={OrderAcceptedScreen} />
|
||||
<Stack.Screen name={RouteNames.ArrivedAtStore} component={ArrivedAtStoreScreen} />
|
||||
<Stack.Screen name={RouteNames.ConfirmPickup} component={ConfirmPickupScreen} />
|
||||
<Stack.Screen name={RouteNames.OrderPickedUp} component={OrderPickedUpScreen} />
|
||||
<Stack.Screen name={RouteNames.LiveTracking} component={LiveTrackingScreen} />
|
||||
<Stack.Screen name={RouteNames.ArrivedAtCustomer} component={ArrivedAtCustomerScreen} />
|
||||
<Stack.Screen name={RouteNames.DeliverOrder} component={DeliverOrderScreen} />
|
||||
<Stack.Screen name={RouteNames.DeliveryCompleted} component={DeliveryCompletedScreen} />
|
||||
<Stack.Screen
|
||||
name={RouteNames.BottomTabs}
|
||||
component={BottomTabNavigator}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.NewJobRequest}
|
||||
component={NewJobRequestScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.OrderAccepted}
|
||||
component={OrderAcceptedScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.ArrivedAtStore}
|
||||
component={ArrivedAtStoreScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.ConfirmPickup}
|
||||
component={ConfirmPickupScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.OrderPickedUp}
|
||||
component={OrderPickedUpScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.LiveTracking}
|
||||
component={LiveTrackingScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.ArrivedAtCustomer}
|
||||
component={ArrivedAtCustomerScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.DeliverOrder}
|
||||
component={DeliverOrderScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name={RouteNames.DeliveryCompleted}
|
||||
component={DeliveryCompletedScreen}
|
||||
/>
|
||||
<Stack.Screen name={RouteNames.Documents} component={DocumentsScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
|
||||
@ -15,7 +15,7 @@ const STORAGE_KEYS = {
|
||||
} as const;
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
const BASE_URL = 'https://157a-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
const BASE_URL = 'https://53a9-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
|
||||
// ─── Token Helpers ───────────────────────────────────────────────────────────
|
||||
export const tokenManager = {
|
||||
|
||||
@ -247,3 +247,134 @@ export const getCurrentLocationWithAddress =
|
||||
|
||||
return { coords, address };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route & Distance helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decodes a Google Maps encoded polyline string.
|
||||
* @param encoded - The encoded polyline string.
|
||||
*/
|
||||
export const decodePolyline = (
|
||||
encoded: string,
|
||||
): { latitude: number; longitude: number }[] => {
|
||||
const points: { latitude: number; longitude: number }[] = [];
|
||||
let index = 0;
|
||||
const len = encoded.length;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
|
||||
while (index < len) {
|
||||
let b;
|
||||
let shift = 0;
|
||||
let result = 0;
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
const dlat = result & 1 ? ~(result >> 1) : result >> 1;
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
const dlng = result & 1 ? ~(result >> 1) : result >> 1;
|
||||
lng += dlng;
|
||||
|
||||
points.push({
|
||||
latitude: lat / 1e5,
|
||||
longitude: lng / 1e5,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch directions coordinates between origin and destination using the Google Maps Directions API.
|
||||
* Falls back to a straight line [origin, destination] if the request fails.
|
||||
*/
|
||||
export const getRouteDirections = async (
|
||||
origin: { latitude: number; longitude: number },
|
||||
destination: { latitude: number; longitude: number },
|
||||
): Promise<{ latitude: number; longitude: number }[]> => {
|
||||
// 1. Try Google Maps Directions API first
|
||||
try {
|
||||
const url =
|
||||
`https://maps.googleapis.com/maps/api/directions/json` +
|
||||
`?origin=${origin.latitude},${origin.longitude}` +
|
||||
`&destination=${destination.latitude},${destination.longitude}` +
|
||||
`&key=${GOOGLE_MAPS_API_KEY}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.status === 'OK' && data.routes && data.routes.length > 0) {
|
||||
console.log('Successfully fetched Google Directions');
|
||||
return decodePolyline(data.routes[0].overview_polyline.points);
|
||||
} else {
|
||||
console.warn(
|
||||
'Google Maps Directions status not OK:',
|
||||
data.status,
|
||||
data.error_message || '',
|
||||
);
|
||||
throw new Error(`Google Directions status: ${data.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error fetching Google Directions, trying OSRM fallback:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Fallback to OpenStreetMap OSRM API (completely free and no key required)
|
||||
try {
|
||||
const osmUrl = `https://router.project-osrm.org/route/v1/driving/${origin.longitude},${origin.latitude};${destination.longitude},${destination.latitude}?overview=full&geometries=geojson`;
|
||||
const response = await fetch(osmUrl);
|
||||
const data = await response.json();
|
||||
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
|
||||
console.log('Successfully fetched OSRM Directions');
|
||||
return data.routes[0].geometry.coordinates.map(
|
||||
(point: [number, number]) => ({
|
||||
latitude: point[1],
|
||||
longitude: point[0],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.warn('OSM OSRM Routing status not Ok:', data.code);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching OSRM directions:', error);
|
||||
}
|
||||
|
||||
// 3. Ultimate Fallback to straight line
|
||||
return [origin, destination];
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the distance between two coordinates in kilometers using the Haversine formula.
|
||||
*/
|
||||
export const getHaversineDistance = (
|
||||
coords1: { latitude: number; longitude: number },
|
||||
coords2: { latitude: number; longitude: number },
|
||||
): number => {
|
||||
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||
const R = 6371; // Earth's radius in km
|
||||
|
||||
const dLat = toRad(coords2.latitude - coords1.latitude);
|
||||
const dLon = toRad(coords2.longitude - coords1.longitude);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(coords1.latitude)) *
|
||||
Math.cos(toRad(coords2.latitude)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c; // Distance in km
|
||||
};
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ 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://157a-202-8-116-13.ngrok-free.app';
|
||||
const BASE_URL = 'https://53a9-202-8-116-13.ngrok-free.app';
|
||||
|
||||
type Callback = (data: any) => void;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user