diff --git a/app/App.tsx b/app/App.tsx index 22fcca9..d774074 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StatusBar, useColorScheme } from 'react-native'; +import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, @@ -10,22 +10,47 @@ import { Provider } from 'react-redux'; import { persistor, store } from './store'; import { RootNavigator } from './navigation/rootNavigator'; import { PersistGate } from 'redux-persist/integration/react'; +import { colors } from '@theme'; function App() { - const isDarkMode = useColorScheme() === 'dark'; - return ( - - - - + ); } +function AppContent() { + const isDarkMode = useColorScheme() === 'dark'; + const safeAreaInsets = useSafeAreaInsets(); // βœ… now inside SafeAreaProvider + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + export default App; diff --git a/app/api/customerDetailsApi.ts b/app/api/customerDetailsApi.ts index f4e2b7b..ffcc7d0 100644 --- a/app/api/customerDetailsApi.ts +++ b/app/api/customerDetailsApi.ts @@ -1,6 +1,10 @@ -import { CustomerResponse } from '@interfaces'; +import { CustomerResponse, WalletResponse } from '@interfaces'; import { apiClient } from '@services'; export const getCustomerDetails = async () => { return await apiClient.get('/customers/profile'); }; + +export const getWalletDetails = async (): Promise => { + return await apiClient.get('/wallets/balance'); +}; diff --git a/app/components/TrackingMap/TrackingMap.styles.ts b/app/components/TrackingMap/TrackingMap.styles.ts new file mode 100644 index 0000000..67024d5 --- /dev/null +++ b/app/components/TrackingMap/TrackingMap.styles.ts @@ -0,0 +1,28 @@ +import { StyleSheet } from 'react-native'; + +export const getStyles = (colors: any) => + StyleSheet.create({ + container: { + flex: 1, + }, + map: { + ...StyleSheet.absoluteFill, + }, + driverMarker: { + backgroundColor: '#FFF', + padding: 6, + borderRadius: 20, + borderWidth: 2, + borderColor: '#FF7F00', + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + justifyContent: 'center', + alignItems: 'center', + }, + driverEmoji: { + fontSize: 20, + }, + }); diff --git a/app/components/TrackingMap/TrackingMap.tsx b/app/components/TrackingMap/TrackingMap.tsx new file mode 100644 index 0000000..13d7766 --- /dev/null +++ b/app/components/TrackingMap/TrackingMap.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { View, Text } from 'react-native'; +import MapView, { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps'; +import { useAppTheme } from '@theme'; +import { getRouteDirections, getHaversineDistance } from '@services'; +import { getStyles } from './TrackingMap.styles'; + +interface TrackingMapProps { + customerDropoff: { latitude: number; longitude: number }; + driverLocation: { + latitude: number; + longitude: number; + heading?: number; + } | null; + showRoute?: boolean; +} + +export const TrackingMap: React.FC = ({ + customerDropoff, + driverLocation, + showRoute = true, +}) => { + const { colors } = useAppTheme(); + const styles = getStyles(colors); + const mapRef = useRef(null); + const [routeCoords, setRouteCoords] = useState< + { latitude: number; longitude: number }[] + >([]); + const lastFetchedLocation = useRef<{ + latitude: number; + longitude: number; + } | null>(null); + + const initialRegion = { + latitude: customerDropoff.latitude, + longitude: customerDropoff.longitude, + latitudeDelta: 0.02, + longitudeDelta: 0.02, + }; + // console.log('[TrackingMap] driverLocation:', driverLocation); + // console.log('[TrackingMap] customerDropoff:', customerDropoff); + // console.log('[TrackingMap] routeCoords:', routeCoords); + // Fetch routing coordinates between driver and customer dropoff + useEffect(() => { + let isMounted = true; + if (showRoute && driverLocation && customerDropoff) { + const currentDriverPos = { + latitude: driverLocation.latitude, + longitude: driverLocation.longitude, + }; + + // Limit routing API calls if the driver has moved less than 50 meters + if (lastFetchedLocation.current) { + const dist = getHaversineDistance( + currentDriverPos, + lastFetchedLocation.current, + ); + if (dist < 0.05 && routeCoords.length > 0) { + return; + } + } + + getRouteDirections(currentDriverPos, customerDropoff) + .then(points => { + if (isMounted) { + setRouteCoords(points); + lastFetchedLocation.current = currentDriverPos; + } + }) + .catch(err => { + console.error( + '[TrackingMap] Error fetching routing directions:', + err, + ); + if (isMounted) { + // Fallback to straight line + setRouteCoords([currentDriverPos, customerDropoff]); + } + }); + } else { + setRouteCoords([]); + lastFetchedLocation.current = null; + } + + return () => { + isMounted = false; + }; + }, [ + showRoute, + driverLocation?.latitude, + driverLocation?.longitude, + customerDropoff?.latitude, + customerDropoff?.longitude, + ]); + + // Adjust camera to fit both the customer dropoff and driver location + useEffect(() => { + const coordsToFit: { latitude: number; longitude: number }[] = []; + if (customerDropoff) { + coordsToFit.push(customerDropoff); + } + if (driverLocation) { + coordsToFit.push({ + latitude: driverLocation.latitude, + longitude: driverLocation.longitude, + }); + } + + if (coordsToFit.length > 0 && mapRef.current) { + const timer = setTimeout(() => { + mapRef.current?.fitToCoordinates(coordsToFit, { + edgePadding: { top: 80, right: 80, bottom: 80, left: 80 }, + animated: true, + }); + }, 500); + return () => clearTimeout(timer); + } + }, [ + customerDropoff?.latitude, + customerDropoff?.longitude, + driverLocation?.latitude, + driverLocation?.longitude, + ]); + + return ( + + + {/* Customer Dropoff Location Marker */} + {customerDropoff && ( + + )} + + {/* Live Driver Marker */} + {driverLocation && ( + + + πŸ›΅ + + + )} + + {/* Live Routing Polyline */} + {showRoute && routeCoords.length > 0 && ( + + )} + + + ); +}; + +export default TrackingMap; diff --git a/app/components/TrackingMap/index.ts b/app/components/TrackingMap/index.ts new file mode 100644 index 0000000..bc4d010 --- /dev/null +++ b/app/components/TrackingMap/index.ts @@ -0,0 +1 @@ +export * from './TrackingMap'; diff --git a/app/components/index.ts b/app/components/index.ts index 00a4a73..63fbf59 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -14,3 +14,5 @@ export * from './ratingStars'; export * from './orderHistoryCard'; export * from './paymentOption'; export * from './OrderStatusTimeline'; +export * from './TrackingMap'; + diff --git a/app/components/orderHistoryCard/orderHistoryCard.props.ts b/app/components/orderHistoryCard/orderHistoryCard.props.ts index 2161c08..a9ff15e 100644 --- a/app/components/orderHistoryCard/orderHistoryCard.props.ts +++ b/app/components/orderHistoryCard/orderHistoryCard.props.ts @@ -6,7 +6,7 @@ export interface OrderHistoryCardProps { orderDate: string; status: string; total: number; - onReorder?: () => void; + onTrack?: () => void; onDetails?: () => void; items?: OrderItem[]; } diff --git a/app/components/orderHistoryCard/orderHistoryCard.tsx b/app/components/orderHistoryCard/orderHistoryCard.tsx index e37a9b8..f58a1ea 100644 --- a/app/components/orderHistoryCard/orderHistoryCard.tsx +++ b/app/components/orderHistoryCard/orderHistoryCard.tsx @@ -10,7 +10,7 @@ export const OrderHistoryCard: React.FC = ({ orderDate, status, total, - onReorder, + onTrack, onDetails, items, }) => { @@ -48,13 +48,13 @@ export const OrderHistoryCard: React.FC = ({ β‚Ή{total} - {onReorder && ( + {onTrack && status === 'OUT_FOR_DELIVERY' && ( - Reorder + Track Order )} {onDetails && ( diff --git a/app/features/screens/LoginScreen/hooks/useLoginScreen.ts b/app/features/screens/LoginScreen/hooks/useLoginScreen.ts index acc485a..710ccf5 100644 --- a/app/features/screens/LoginScreen/hooks/useLoginScreen.ts +++ b/app/features/screens/LoginScreen/hooks/useLoginScreen.ts @@ -3,6 +3,7 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { AuthStackParamList } from 'app/navigation/authStack'; import { loginWithPhone, useAppDispatch } from '@store'; +import { Alert } from 'react-native'; type LoginNavProp = StackNavigationProp; @@ -50,8 +51,14 @@ export const useLoginScreen = (): UseLoginScreenResult => { } setError(undefined); - dispatch(loginWithPhone(mobileNumber)); - navigation.navigate('OtpScreen', { mobileNumber }); + dispatch(loginWithPhone(mobileNumber)) + .unwrap() + .then(() => { + navigation.navigate('OtpScreen', { mobileNumber }); + }) + .catch(err => { + Alert.alert(err); + }); }, [mobileNumber, dispatch, navigation]); return { diff --git a/app/features/screens/accountScreen/accountScreen.tsx b/app/features/screens/accountScreen/accountScreen.tsx index 3bc53a6..24b5dc6 100644 --- a/app/features/screens/accountScreen/accountScreen.tsx +++ b/app/features/screens/accountScreen/accountScreen.tsx @@ -1,18 +1,19 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; import { useNavigation, CompositeNavigationProp, + useFocusEffect, } from '@react-navigation/native'; import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; import { StackNavigationProp } from '@react-navigation/stack'; import { getStyles } from './accountScreen.styles'; import { Header, PrimaryButton } from '@components'; import { useAppTheme } from '@theme'; -import { logout } from '../../../store/commonreducers/auth'; import { AppStackParamList } from '../../../navigation/appStack'; import { MainTabParamList } from '../../../navigation/mainTabNavigator'; -import { useAppDispatch, useAppSelector } from '@store'; +import { logoutUser, useAppDispatch, useAppSelector } from '@store'; +import { getWalletBalanceThunk } from './thunk'; type AccountNavProp = CompositeNavigationProp< BottomTabNavigationProp, @@ -44,7 +45,7 @@ const MENU_SECTIONS: MenuSectionData[] = [ }, { icon: 'πŸ’³', - label: 'Payment Methods', + label: 'Wallet', subLabel: 'Cards, UPI & wallets', tint: '#FFF4E5', }, @@ -87,7 +88,16 @@ export const AccountScreen: React.FC = () => { const styles = getStyles(colors); const dispatch = useAppDispatch(); const navigation = useNavigation(); - const user = useAppSelector(state => state.auth.user); + const user = useAppSelector( + state => state.customerProfile.customerDetails?.user, + ); + const { wallet } = useAppSelector(state => state.account); + + useFocusEffect( + useCallback(() => { + dispatch(getWalletBalanceThunk()); + }, []), + ); // TODO: wire these to real selectors once order/wallet state is available. // const ordersCount = user?.ordersCount ?? 0; @@ -189,7 +199,10 @@ export const AccountScreen: React.FC = () => { {/* Logout */} - dispatch(logout())} /> + dispatch(logoutUser())} + /> App version 1.0.0 diff --git a/app/features/screens/accountScreen/index.ts b/app/features/screens/accountScreen/index.ts index efe9de3..98d361c 100644 --- a/app/features/screens/accountScreen/index.ts +++ b/app/features/screens/accountScreen/index.ts @@ -1 +1,3 @@ export * from './accountScreen'; +export * from './thunk'; +export * from './reducer'; diff --git a/app/features/screens/accountScreen/reducer.ts b/app/features/screens/accountScreen/reducer.ts new file mode 100644 index 0000000..04b22e6 --- /dev/null +++ b/app/features/screens/accountScreen/reducer.ts @@ -0,0 +1,26 @@ +import { createReducer } from '@reduxjs/toolkit'; +import { WalletResponse } from '@interfaces'; +import { getWalletBalanceThunk } from './thunk'; + +export interface WalletState { + wallet: WalletResponse; + error: string | null; +} + +const initialState: WalletState = { + wallet: { + walletId: '', + currency: '', + balance: 0, + }, + error: null, +}; + +export const accountReducer = createReducer(initialState, builder => { + builder.addCase(getWalletBalanceThunk.fulfilled, (state, action) => { + state.wallet = action.payload; + }); + builder.addCase(getWalletBalanceThunk.rejected, (state, action) => { + state.error = action.payload as string; + }); +}); diff --git a/app/features/screens/accountScreen/thunk.ts b/app/features/screens/accountScreen/thunk.ts new file mode 100644 index 0000000..8899e0f --- /dev/null +++ b/app/features/screens/accountScreen/thunk.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getWalletDetails } from '@api'; + +export const getWalletBalanceThunk = createAsyncThunk( + 'wallet/getWalletBalance', + async (_, { rejectWithValue }) => { + try { + const data = await getWalletDetails(); + return data; + } catch (error) { + return rejectWithValue(error); + } + }, +); diff --git a/app/features/screens/liveTrackingScreen/hooks/index.ts b/app/features/screens/liveTrackingScreen/hooks/index.ts new file mode 100644 index 0000000..aaf348c --- /dev/null +++ b/app/features/screens/liveTrackingScreen/hooks/index.ts @@ -0,0 +1 @@ +export * from './useOrderTracking'; diff --git a/app/features/screens/liveTrackingScreen/hooks/useOrderTracking.ts b/app/features/screens/liveTrackingScreen/hooks/useOrderTracking.ts new file mode 100644 index 0000000..38293cb --- /dev/null +++ b/app/features/screens/liveTrackingScreen/hooks/useOrderTracking.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { socketService, DriverLocationUpdate } from '@services'; + +export function useOrderTracking(orderId: string, userJwtToken: string | null) { + const [driverLocation, setDriverLocation] = + useState(null); + const [orderStatus, setOrderStatus] = useState(null); + + useEffect(() => { + if (!orderId || !userJwtToken) { + return; + } + + console.log(`[useOrderTracking] Connecting socket for orderId: ${orderId}`); + socketService.connect(userJwtToken); + socketService.joinOrderTracking(orderId); + + const handleDriverLocationUpdate = (data: DriverLocationUpdate) => { + console.log('[useOrderTracking] handleDriverLocationUpdate:', data); + if (data.orderId === orderId) { + setDriverLocation(data); + } + }; + + const handleOrderStatusUpdate = (data: any) => { + if (data.orderId === orderId) { + setOrderStatus(data.status); + if (data.status === 'DELIVERED') { + console.log( + '[useOrderTracking] Order delivered. Disconnecting socket.', + ); + socketService.disconnect(orderId); + } + } + }; + + socketService.onDriverLocationUpdate(handleDriverLocationUpdate); + socketService.onOrderStatusUpdate(handleOrderStatusUpdate); + + return () => { + console.log( + `[useOrderTracking] Cleaning up tracking socket for orderId: ${orderId}`, + ); + socketService.disconnect(orderId); + }; + }, [orderId, userJwtToken]); + + return { driverLocation, orderStatus }; +} diff --git a/app/features/screens/liveTrackingScreen/index.ts b/app/features/screens/liveTrackingScreen/index.ts index 293397b..dbff3ba 100644 --- a/app/features/screens/liveTrackingScreen/index.ts +++ b/app/features/screens/liveTrackingScreen/index.ts @@ -1 +1,2 @@ export * from './liveTrackingScreen'; +export * from './hooks'; diff --git a/app/features/screens/liveTrackingScreen/liveTrackingScreen.tsx b/app/features/screens/liveTrackingScreen/liveTrackingScreen.tsx index 0e5304b..447b384 100644 --- a/app/features/screens/liveTrackingScreen/liveTrackingScreen.tsx +++ b/app/features/screens/liveTrackingScreen/liveTrackingScreen.tsx @@ -1,13 +1,19 @@ -import React from 'react'; -import { View, Text } from 'react-native'; +import React, { useEffect } from 'react'; +import { View, Text, ActivityIndicator } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { getStyles } from './liveTrackingScreen.styles'; -import { Header, PrimaryButton } from '@components'; +import { Header, PrimaryButton, TrackingMap } from '@components'; import { useAppTheme } from '@theme'; import { AppStackParamList } from '../../../navigation/appStack'; +import { RootState, useAppDispatch, useAppSelector } from '@store'; +import { getOrderByIdThunk } from '../checkoutPaymentScreen'; +import { useOrderTracking } from './hooks'; -type LiveTrackingNavProp = StackNavigationProp; +type LiveTrackingNavProp = StackNavigationProp< + AppStackParamList, + 'LiveTrackingScreen' +>; type LiveTrackingRouteProp = RouteProp; export const LiveTrackingScreen: React.FC = () => { @@ -17,23 +23,73 @@ export const LiveTrackingScreen: React.FC = () => { const route = useRoute(); const orderId = route.params?.orderId || 'ORD-123456'; + const dispatch = useAppDispatch(); + const { accessToken } = useAppSelector((state: RootState) => state.auth); + const { orderDetails, orderDetailsLoading } = useAppSelector( + (state: RootState) => state.paymentMethods, + ); + // console.log('[LiveTrackingScreen] orderDetails:', orderDetails); + + const { driverLocation, orderStatus } = useOrderTracking( + orderId, + accessToken, + ); + + // Fetch order details if not matching or missing + useEffect(() => { + if (orderId && (!orderDetails || orderDetails.id !== orderId)) { + dispatch(getOrderByIdThunk(orderId)); + } + }, [dispatch, orderId, orderDetails]); + + // Navigate to delivered screen once status transitions to DELIVERED + useEffect(() => { + if (orderStatus === 'DELIVERED') { + console.log( + `[LiveTrackingScreen] Order ${orderId} delivered! Navigating to OrderDeliveredScreen.`, + ); + navigation.navigate('OrderDeliveredScreen', { orderId }); + } + }, [orderStatus, navigation, orderId]); + + if (orderDetailsLoading || !orderDetails) { + return ( + +
navigation.goBack()} /> + + + + Loading tracking details... + + + + ); + } + + // Get drop address coordinates from orderDetails + const dropCoords = { + latitude: orderDetails.dropAddress?.latitude || 12.9352, + longitude: orderDetails.dropAddress?.longitude || 77.6245, + }; + return (
navigation.goBack()} /> - - πŸ—ΊοΈ - Live Map - - Delivery partner location tracking for {orderId} - - - ● Live - + + navigation.navigate('OrderDeliveredScreen', { orderId })} + onPress={() => + navigation.navigate('OrderDeliveredScreen', { orderId }) + } style={{ marginBottom: 16 }} /> Rahul Sharma @@ -55,3 +111,4 @@ export const LiveTrackingScreen: React.FC = () => { ); }; +export default LiveTrackingScreen; diff --git a/app/features/screens/myOrdersScreen/myOrdersScreen.tsx b/app/features/screens/myOrdersScreen/myOrdersScreen.tsx index 3f0e179..9458986 100644 --- a/app/features/screens/myOrdersScreen/myOrdersScreen.tsx +++ b/app/features/screens/myOrdersScreen/myOrdersScreen.tsx @@ -57,7 +57,7 @@ export const MyOrdersScreen: React.FC = () => { const { orderHistory } = useAppSelector( (state: RootState) => state.paymentMethods, ); - console.log(orderHistory); + // console.log(orderHistory); useEffect(() => { dispatch(getOrderHistoryThunk()); @@ -114,29 +114,34 @@ export const MyOrdersScreen: React.FC = () => { status={item.status} total={Number(item?.totalAmount) || 0} items={item.orderItems} - onReorder={() => { - navigation.navigate('ProviderDetailsScreen', { - providerId: 'p1', - providerName: item?.merchant?.name || '', - }); - }} - onDetails={() => { - if ( - item?.status === 'PENDING' || - item?.status === 'CONFIRMED' || - item?.status === 'PREPARING' || - item?.status === 'READY_FOR_PICKUP' || - item?.status === 'OUT_FOR_DELIVERY' - ) { - navigation.navigate('OrderDetailsScreen', { - orderId: item.id, - }); - } else { - navigation.navigate('OrderDeliveredScreen', { - orderId: item.id, + onTrack={() => { + if (item?.status === 'OUT_FOR_DELIVERY') { + // navigation.navigate('ProviderDetailsScreen', { + // providerName: item?.merchant?.name || '', + // }); + // } else { + navigation.navigate('LiveTrackingScreen', { + orderId: item?.id || '', }); } }} + onDetails={() => { + // if ( + // item?.status === 'PENDING' || + // item?.status === 'CONFIRMED' || + // item?.status === 'PREPARING' || + // item?.status === 'READY_FOR_PICKUP' || + // item?.status === 'OUT_FOR_DELIVERY' + // ) { + navigation.navigate('OrderDetailsScreen', { + orderId: item.id, + }); + // } else { + // navigation.navigate('OrderDeliveredScreen', { + // orderId: item.id, + // }); + // } + }} /> )} contentContainerStyle={styles.list} diff --git a/app/interfaces/customerProfile.ts b/app/interfaces/customerProfile.ts index 105b5fa..186ae9d 100644 --- a/app/interfaces/customerProfile.ts +++ b/app/interfaces/customerProfile.ts @@ -47,3 +47,9 @@ export interface CustomerAddress { // export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED'; export type AddressLabel = 'Home' | 'Work' | 'Other'; + +export interface WalletResponse { + walletId: string; + currency: string; + balance: number; +} diff --git a/app/navigation/appStack.tsx b/app/navigation/appStack.tsx index 6fa7868..0f32658 100644 --- a/app/navigation/appStack.tsx +++ b/app/navigation/appStack.tsx @@ -20,7 +20,7 @@ import { export type AppStackParamList = { MainTabs: NavigatorScreenParams | undefined; ProviderListScreen: { category?: string } | undefined; - ProviderDetailsScreen: { providerId: string; providerName?: string }; + ProviderDetailsScreen: { providerId?: string; providerName?: string }; CartScreen: undefined; CheckoutAddressScreen: undefined; CheckoutPaymentScreen: { selectedAddressId?: string } | undefined; @@ -68,10 +68,7 @@ export const AppStack: React.FC = () => { /> - + ); }; diff --git a/app/services/apiClient.ts b/app/services/apiClient.ts index 9e7ba68..4bc15fd 100644 --- a/app/services/apiClient.ts +++ b/app/services/apiClient.ts @@ -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://2dbb-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL // ─── Token Helpers ─────────────────────────────────────────────────────────── export const tokenManager = { @@ -131,7 +131,7 @@ axiosInstance.interceptors.response.use( } // Call refresh endpoint (uses a fresh axios call to avoid interceptor loop) - const { data } = await axios.post(`${BASE_URL}/auth/refresh-token`, { + const { data } = await axios.post(`${BASE_URL}/auth/refresh`, { refreshToken, }); diff --git a/app/services/index.ts b/app/services/index.ts index ca175f6..66fa671 100644 --- a/app/services/index.ts +++ b/app/services/index.ts @@ -1,2 +1,3 @@ export * from './apiClient'; export * from './locationServices'; +export * from './socketService'; diff --git a/app/services/locationServices.ts b/app/services/locationServices.ts index d13fbee..e92c780 100644 --- a/app/services/locationServices.ts +++ b/app/services/locationServices.ts @@ -227,3 +227,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, +): LatLng[] => { + const points: LatLng[] = []; + 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: LatLng, + destination: LatLng, +): Promise => { + // 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: LatLng, + coords2: LatLng, +): 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 +}; + diff --git a/app/services/socketService.ts b/app/services/socketService.ts new file mode 100644 index 0000000..5189e5a --- /dev/null +++ b/app/services/socketService.ts @@ -0,0 +1,94 @@ +import { io, Socket } from 'socket.io-client'; + +const BASE_URL = 'https://2dbb-202-8-116-13.ngrok-free.app'; +const SOCKET_URL = `${BASE_URL}/tracking`; + +export interface DriverLocationUpdate { + orderId: string; + latitude: number; + longitude: number; + heading?: number; + speedKph?: number; + recordedAt: string; +} + +export interface TrackingStatusUpdate { + orderId: string; + status: 'OUT_FOR_DELIVERY' | 'DELIVERED'; + timestamp: string; +} + +class SocketService { + private socket: Socket | null = null; + + connect(token: string): Socket { + if (this.socket?.connected) { + console.log( + '[Socket] Already connected to Tracking Namespace', + this.socket, + ); + return this.socket; + } + + this.socket = io(SOCKET_URL, { + auth: { token }, + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 2000, + }); + + this.socket.on('connect', () => { + console.log('[Socket] Connected to Tracking Namespace'); + }); + + this.socket.on('connect_error', error => { + console.error('[Socket] Connection Error:', error.message); + }); + + this.socket.on('disconnect', reason => { + console.log('[Socket] Disconnected from Tracking Namespace:', reason); + }); + + return this.socket; + } + + joinOrderTracking(orderId: string) { + this.socket?.emit('join_order_tracking', { orderId }); + console.log(`[Socket] Emitted join_order_tracking for order: ${orderId}`); + } + + leaveOrderTracking(orderId: string) { + this.socket?.emit('leave_order_tracking', { orderId }); + console.log(`[Socket] Emitted leave_order_tracking for order: ${orderId}`); + } + + onDriverLocationUpdate(callback: (data: DriverLocationUpdate) => void) { + console.log(callback); + this.socket?.on('driver_location_update', callback); + } + + onOrderStatusUpdate(callback: (data: TrackingStatusUpdate) => void) { + this.socket?.on('order_tracking_status', callback); + } + + disconnect(orderId?: string) { + if (this.socket) { + if (orderId) { + this.leaveOrderTracking(orderId); + } + this.socket.off('driver_location_update'); + this.socket.off('order_tracking_status'); + this.socket.disconnect(); + this.socket = null; + console.log('[Socket] Disconnected manually'); + } + } + + get connected(): boolean { + return this.socket?.connected ?? false; + } +} + +export const socketService = new SocketService(); +export default socketService; diff --git a/app/store/commonreducers/index.ts b/app/store/commonreducers/index.ts index 6304c85..cef4eba 100644 --- a/app/store/commonreducers/index.ts +++ b/app/store/commonreducers/index.ts @@ -2,3 +2,4 @@ export * from './auth'; export * from './cart'; export * from './order'; export * from './customerProfile'; +export * from './offer'; diff --git a/app/store/rootReducer.ts b/app/store/rootReducer.ts index fae5a19..bbbb08f 100644 --- a/app/store/rootReducer.ts +++ b/app/store/rootReducer.ts @@ -11,6 +11,7 @@ import providerDetailsReducer from '@features/screens/providerDetailsScreen/redu import paymentMethodsReducer from '@features/screens/checkoutPaymentScreen/reducer'; import customerProfileReducer from './commonreducers/customerProfile/reducer'; import { offerReducer } from './commonreducers/offer'; +import { accountReducer } from '@features/screens'; const rootReducer = combineReducers({ auth: authReducer, @@ -24,6 +25,7 @@ const rootReducer = combineReducers({ paymentMethods: paymentMethodsReducer, customerProfile: customerProfileReducer, offer: offerReducer, + account: accountReducer, }); export type RootState = ReturnType; diff --git a/app/utils/helper.ts b/app/utils/helper.ts index 22b2e6e..eb9a59c 100644 --- a/app/utils/helper.ts +++ b/app/utils/helper.ts @@ -1,5 +1,5 @@ export const getFullUrl = (url?: string) => { - const BASE_URL = 'https://157a-202-8-116-13.ngrok-free.app'; + const BASE_URL = 'https://2dbb-202-8-116-13.ngrok-free.app'; if (!url) return ''; return url.startsWith('/') ? `${BASE_URL}${url}` : url; }; diff --git a/package.json b/package.json index 60b8bb3..ed85aea 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "react-redux": "^9.3.0", "reactotron-react-native": "^5.2.0", "redux-persist": "^6.0.0", - "redux-thunk": "^3.1.0" + "redux-thunk": "^3.1.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/yarn.lock b/yarn.lock index 644c061..2597882 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1837,6 +1837,11 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== + "@standard-schema/spec@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" @@ -2929,7 +2934,7 @@ debug@2.6.9, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -3094,6 +3099,22 @@ encodeurl@~2.0.0: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== +engine.io-client@~6.6.1: + version "6.6.6" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.6.tgz#8a8f1e451b1f6d4acf413305445e42133d1cbe9a" + integrity sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.4.1" + engine.io-parser "~5.2.1" + ws "~8.21.0" + xmlhttprequest-ssl "~2.1.1" + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + entities@^4.2.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" @@ -6379,6 +6400,24 @@ slice-ansi@^2.0.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +socket.io-client@^4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.8.3.tgz#62717edd46a318c918125b57e92dc7f8bb71c34c" + integrity sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.4.1" + engine.io-client "~6.6.1" + socket.io-parser "~4.2.4" + +socket.io-parser@~4.2.4: + version "4.2.7" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz#679e51fe24d1c81df90fc5f7efe4a5f432fe99c0" + integrity sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.4.1" + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" @@ -6987,6 +7026,16 @@ ws@^7, ws@^7.5.10: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.11.tgz#9460daf1812bb81a423c5b9eac746941a86310fa" integrity sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA== +ws@~8.21.0: + version "8.21.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" + integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== + +xmlhttprequest-ssl@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23" + integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ== + y18n@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"