diff --git a/ReactotronConfig.js b/ReactotronConfig.js new file mode 100644 index 0000000..20146cb --- /dev/null +++ b/ReactotronConfig.js @@ -0,0 +1,5 @@ +import Reactotron from 'reactotron-react-native'; + +Reactotron.configure() // controls connection & communication settings + .useReactNative() // add all built-in react native plugins + .connect(); // let's connect! diff --git a/android/build.gradle b/android/build.gradle index dad99b0..997cc0f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,6 +6,20 @@ buildscript { targetSdkVersion = 36 ndkVersion = "27.1.12297006" kotlinVersion = "2.1.20" + + // --------------------------------------------------------------- + // Pin Google Play Services versions so all libraries use the same + // artifact. react-native-geolocation-service defaults to 18.0.0 + // but react-native-maps requires 21.x where FusedLocationProvider- + // Client is an interface, not a class. Using two different versions + // causes IncompatibleClassChangeError and a hard app crash. + // --------------------------------------------------------------- + googlePlayServicesVersion = "21.3.0" // used by geolocation-service + playServicesVersion = "21.3.0" // fallback alias + playServicesLocationVersion = "21.3.0" // used by geolocation-service + googlePlayServicesLocationVersion = "21.3.0" // used by react-native-maps + googlePlayServicesMapsVersion = "19.1.0" + googlePlayServicesBaseVersion = "18.5.0" } repositories { google() @@ -19,3 +33,13 @@ buildscript { } apply plugin: "com.facebook.react.rootproject" + +// Force a single version of play-services-location across ALL modules +// to prevent IncompatibleClassChangeError at runtime. +subprojects { + configurations.all { + resolutionStrategy { + force "com.google.android.gms:play-services-location:21.3.0" + } + } +} diff --git a/app/api/authApi.ts b/app/api/authApi.ts index 481e5a1..a0a4ee6 100644 --- a/app/api/authApi.ts +++ b/app/api/authApi.ts @@ -1,20 +1,8 @@ import { apiClient } from '../services/apiClient'; -import { User } from '../interfaces'; +import { LoginResponse, User, VerifyOtpResponse } from '../interfaces'; // ─── Types ─────────────────────────────────────────────────────────────────── -export interface LoginResponse { - success: boolean; - message: string; -} - -export interface VerifyOtpResponse { - success: boolean; - user: User; - accessToken: string; - refreshToken: string; -} - export interface UpdateProfileResponse { success: boolean; user: User; @@ -26,11 +14,11 @@ export interface LogoutResponse { // ─── Endpoints ─────────────────────────────────────────────────────────────── -export const loginApi = (mobileNumber: string) => - apiClient.post('/auth/login', { mobileNumber }); +export const loginApi = (phone: string) => + apiClient.post('/auth/otp/request', { phone }); -export const verifyOtpApi = (mobileNumber: string, otp: string) => - apiClient.post('/auth/verify-otp', { mobileNumber, otp }); +export const verifyOtpApi = (phone: string, code: string, role: string) => + apiClient.post('/auth/otp/verify', { phone, code, role }); export const updateProfileApi = (data: { name: string; diff --git a/app/features/screens/LoginScreen/LoginScreen.tsx b/app/features/screens/LoginScreen/LoginScreen.tsx index 10a8617..161874b 100644 --- a/app/features/screens/LoginScreen/LoginScreen.tsx +++ b/app/features/screens/LoginScreen/LoginScreen.tsx @@ -9,7 +9,8 @@ import { import { getStyles } from './loginScreen.styles'; import { CustomInput, PrimaryButton } from '@components'; import { useAppTheme } from '@theme'; -import { useLoginScreen } from '../../../hooks'; +import { useLoginScreen } from './hooks/useLoginScreen'; +import { RootState, useAppSelector } from '@store'; export const LoginScreen: React.FC = () => { const { colors } = useAppTheme(); @@ -18,6 +19,8 @@ export const LoginScreen: React.FC = () => { const { mobileNumber, error, onChangeMobileNumber, handleLogin } = useLoginScreen(); + const { isLoading } = useAppSelector((state: RootState) => state.auth); + return ( { )} diff --git a/app/hooks/useLoginScreen.ts b/app/features/screens/LoginScreen/hooks/useLoginScreen.ts similarity index 96% rename from app/hooks/useLoginScreen.ts rename to app/features/screens/LoginScreen/hooks/useLoginScreen.ts index 5c742fe..acc485a 100644 --- a/app/hooks/useLoginScreen.ts +++ b/app/features/screens/LoginScreen/hooks/useLoginScreen.ts @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { AuthStackParamList } from 'app/navigation/authStack'; -import { loginWithPhone, useAppDispatch } from '../store'; +import { loginWithPhone, useAppDispatch } from '@store'; type LoginNavProp = StackNavigationProp; diff --git a/app/features/screens/accountScreen/accountScreen.tsx b/app/features/screens/accountScreen/accountScreen.tsx index ab3c5e8..b2d34ef 100644 --- a/app/features/screens/accountScreen/accountScreen.tsx +++ b/app/features/screens/accountScreen/accountScreen.tsx @@ -9,10 +9,10 @@ import { StackNavigationProp } from '@react-navigation/stack'; import { getStyles } from './accountScreen.styles'; import { Header, PrimaryButton } from '@components'; import { useAppTheme } from '@theme'; -import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch'; import { logout } from '../../../store/commonreducers/auth'; import { AppStackParamList } from '../../../navigation/appStack'; import { MainTabParamList } from '../../../navigation/mainTabNavigator'; +import { useAppDispatch, useAppSelector } from '@store'; type AccountNavProp = CompositeNavigationProp< BottomTabNavigationProp, diff --git a/app/features/screens/cartScreen/cartScreen.tsx b/app/features/screens/cartScreen/cartScreen.tsx index 4c63f83..b56aa0d 100644 --- a/app/features/screens/cartScreen/cartScreen.tsx +++ b/app/features/screens/cartScreen/cartScreen.tsx @@ -11,7 +11,6 @@ import { StackNavigationProp } from '@react-navigation/stack'; import { getStyles } from './cartScreen.styles'; import { Header, QuantitySelector, PrimaryButton } from '@components'; import { useAppTheme } from '@theme'; -import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch'; import { removeItem, applyCoupon, @@ -19,6 +18,7 @@ import { selectCartTotal, } from '../../../store/commonreducers/cart'; import { AppStackParamList } from '../../../navigation/appStack'; +import { useAppDispatch, useAppSelector } from '@store'; type CartScreenNavProp = StackNavigationProp; diff --git a/app/features/screens/completeProfileScreen/completeProfileScreen.tsx b/app/features/screens/completeProfileScreen/completeProfileScreen.tsx index 23f2bbd..1bafa94 100644 --- a/app/features/screens/completeProfileScreen/completeProfileScreen.tsx +++ b/app/features/screens/completeProfileScreen/completeProfileScreen.tsx @@ -12,9 +12,10 @@ import { StackNavigationProp } from '@react-navigation/stack'; import { getStyles } from './completeProfileScreen.styles'; import { CustomInput, PrimaryButton } from '@components'; import { useAppTheme } from '@theme'; -import { useAppDispatch } from '../../../hooks/useAppDispatch'; + import { completeProfile } from '../../../store/commonreducers/auth'; import { AuthStackParamList } from '../../../navigation/authStack'; +import { useAppDispatch } from '@store'; type NavProp = StackNavigationProp; @@ -61,7 +62,7 @@ export const CompleteProfileScreen: React.FC = () => { Location Label - {LOCATION_LABELS.map((label) => ( + {LOCATION_LABELS.map(label => ( { const { colors } = useAppTheme(); @@ -25,10 +26,7 @@ export const OnboardingCompleteScreen: React.FC = () => { - + ); diff --git a/app/features/screens/otpScreen/hooks/useOtpScreen.ts b/app/features/screens/otpScreen/hooks/useOtpScreen.ts new file mode 100644 index 0000000..b619e60 --- /dev/null +++ b/app/features/screens/otpScreen/hooks/useOtpScreen.ts @@ -0,0 +1,108 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Alert } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { StackNavigationProp } from '@react-navigation/stack'; +import { RootState, useAppDispatch, useAppSelector, verifyOtp } from '@store'; +import { AuthStackParamList } from '@navigation/authStack'; + +type OtpNavProp = StackNavigationProp; +type OtpRouteProp = RouteProp; + +const OTP_LENGTH = 6; +const RESEND_TIMER = 30; + +export const useOtpScreen = () => { + const navigation = useNavigation(); + const route = useRoute(); + const dispatch = useAppDispatch(); + const { mobileNumber } = route.params; + + const { loginData, user } = useAppSelector((state: RootState) => state.auth); + + const [otp, setOtp] = useState(Array(OTP_LENGTH).fill('')); + const [activeIndex, setActiveIndex] = useState(0); + const [timer, setTimer] = useState(RESEND_TIMER); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (timer > 0) { + const interval = setInterval(() => setTimer(t => t - 1), 1000); + return () => clearInterval(interval); + } + }, [timer]); + + const resetOtp = useCallback(() => { + setOtp(Array(OTP_LENGTH).fill('')); + setActiveIndex(0); + }, []); + + const handleKeyPress = useCallback( + (key: string) => { + if (key === 'backspace') { + if (otp[activeIndex] || activeIndex > 0) { + const newOtp = [...otp]; + if (otp[activeIndex]) { + newOtp[activeIndex] = ''; + } else if (activeIndex > 0) { + newOtp[activeIndex - 1] = ''; + setActiveIndex(activeIndex - 1); + } + setOtp(newOtp); + } + } else if (key >= '0' && key <= '9' && activeIndex < OTP_LENGTH) { + const newOtp = [...otp]; + newOtp[activeIndex] = key; + setOtp(newOtp); + if (activeIndex < OTP_LENGTH - 1) { + setActiveIndex(activeIndex + 1); + } + } + }, + [otp, activeIndex], + ); + + const handleResend = useCallback(() => { + setTimer(RESEND_TIMER); + resetOtp(); + // TODO: dispatch resend/loginWithPhone thunk here if needed + }, [resetOtp]); + + const handleVerify = useCallback(async () => { + const otpString = otp.join(''); + if (otpString.length !== OTP_LENGTH) return; + + setIsLoading(true); + try { + await dispatch( + verifyOtp({ phone: mobileNumber, code: otpString, role: 'CUSTOMER' }), + ).unwrap(); + + navigation.navigate('SetLocationScreen'); + } catch (error) { + const message = + typeof error === 'string' ? error : 'Invalid OTP. Please try again.'; + Alert.alert('Verification Failed', message); + resetOtp(); + } finally { + setIsLoading(false); + } + }, [dispatch, mobileNumber, navigation, otp, resetOtp]); + + return { + // data + mobileNumber, + loginData, + user, + otp, + activeIndex, + timer, + isLoading, + otpLength: OTP_LENGTH, + isOtpComplete: otp.join('').length === OTP_LENGTH, + + // actions + handleKeyPress, + handleVerify, + handleResend, + }; +}; diff --git a/app/features/screens/otpScreen/otpScreen.tsx b/app/features/screens/otpScreen/otpScreen.tsx index f4e27c8..b9f1b51 100644 --- a/app/features/screens/otpScreen/otpScreen.tsx +++ b/app/features/screens/otpScreen/otpScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { View, Text, @@ -6,71 +6,27 @@ import { KeyboardAvoidingView, Platform, } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { StackNavigationProp } from '@react-navigation/stack'; import { getStyles } from './otpScreen.styles'; import { PrimaryButton } from '@components'; import { useAppTheme } from '@theme'; -import { useAppDispatch } from '../../../hooks/useAppDispatch'; -import { verifyOtp } from '../../../store/commonreducers/auth'; -import { AuthStackParamList } from '../../../navigation/authStack'; - -type OtpNavProp = StackNavigationProp; -type OtpRouteProp = RouteProp; - -const OTP_LENGTH = 6; -const RESEND_TIMER = 30; +import { useOtpScreen } from './hooks/useOtpScreen'; export const OtpScreen: React.FC = () => { const { colors } = useAppTheme(); const styles = getStyles(colors); - const navigation = useNavigation(); - const route = useRoute(); - const dispatch = useAppDispatch(); - const { mobileNumber } = route.params; - const [otp, setOtp] = useState(Array(OTP_LENGTH).fill('')); - const [activeIndex, setActiveIndex] = useState(0); - const [timer, setTimer] = useState(RESEND_TIMER); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - if (timer > 0) { - const interval = setInterval(() => setTimer((t) => t - 1), 1000); - return () => clearInterval(interval); - } - }, [timer]); - - const handleKeyPress = (key: string) => { - if (key === 'backspace') { - if (otp[activeIndex] || activeIndex > 0) { - const newOtp = [...otp]; - if (otp[activeIndex]) { - newOtp[activeIndex] = ''; - } else if (activeIndex > 0) { - newOtp[activeIndex - 1] = ''; - setActiveIndex(activeIndex - 1); - } - setOtp(newOtp); - } - } else if (key >= '0' && key <= '9' && activeIndex < OTP_LENGTH) { - const newOtp = [...otp]; - newOtp[activeIndex] = key; - setOtp(newOtp); - if (activeIndex < OTP_LENGTH - 1) { - setActiveIndex(activeIndex + 1); - } - } - }; - - const handleVerify = async () => { - const otpString = otp.join(''); - if (otpString.length !== OTP_LENGTH) return; - setIsLoading(true); - await dispatch(verifyOtp({ mobileNumber, otp: otpString })); - setIsLoading(false); - navigation.navigate('SetLocationScreen'); - }; + const { + mobileNumber, + otp, + activeIndex, + timer, + isLoading, + otpLength, + isOtpComplete, + handleKeyPress, + handleVerify, + handleResend, + } = useOtpScreen(); return ( { > Verify OTP - - Code sent to {mobileNumber} - + Code sent to {mobileNumber} - {otp.map((digit, index) => ( - - {digit} - - ))} + {otp.map( + ( + digit: + | string + | number + | bigint + | boolean + | React.ReactElement< + unknown, + string | React.JSXElementConstructor + > + | Iterable + | React.ReactPortal + | Promise< + | string + | number + | bigint + | boolean + | React.ReactPortal + | React.ReactElement< + unknown, + string | React.JSXElementConstructor + > + | Iterable + | null + | undefined + > + | null + | undefined, + index: React.Key | null | undefined, + ) => ( + + {digit} + + ), + )} - {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => ( + {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => ( { {timer > 0 ? ( Resend code in {timer}s ) : ( - { - setTimer(RESEND_TIMER); - setOtp(Array(OTP_LENGTH).fill('')); - setActiveIndex(0); - }} - activeOpacity={0.7} - > + Resend OTP )} @@ -145,7 +123,7 @@ export const OtpScreen: React.FC = () => { title="Verify" onPress={handleVerify} isLoading={isLoading} - disabled={otp.join('').length !== OTP_LENGTH} + disabled={!isOtpComplete} style={{ marginTop: 24 }} /> diff --git a/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx b/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx index d10cab8..cd69d41 100644 --- a/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx +++ b/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx @@ -12,11 +12,14 @@ import { StackNavigationProp } from '@react-navigation/stack'; import { getStyles } from './providerDetailsScreen.styles'; import { CatalogItemRow } from '@components'; import { useAppTheme } from '@theme'; -import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch'; import { addItem } from '../../../store/commonreducers/cart'; -import { getProviderCatalogApi, getProvidersApi } from '../../../api/deliveryApi'; +import { + getProviderCatalogApi, + getProvidersApi, +} from '../../../api/deliveryApi'; import { CatalogItem, Provider } from '../../../interfaces'; import { AppStackParamList } from '../../../navigation/appStack'; +import { useAppDispatch, useAppSelector } from '@store'; type ProviderDetailsNavProp = StackNavigationProp< AppStackParamList, diff --git a/app/features/screens/setLocationScreen/setLocationScreen.tsx b/app/features/screens/setLocationScreen/setLocationScreen.tsx index db6d097..7fba6c0 100644 --- a/app/features/screens/setLocationScreen/setLocationScreen.tsx +++ b/app/features/screens/setLocationScreen/setLocationScreen.tsx @@ -3,8 +3,8 @@ import { View, Text, TouchableOpacity, - Platform, ActivityIndicator, + InteractionManager, } from 'react-native'; import MapView, { Marker, Region } from 'react-native-maps'; import { useNavigation } from '@react-navigation/native'; @@ -17,6 +17,7 @@ import { DEFAULT_LOCATION, getCurrentLocationWithAddress, reverseGeocode, + showPermissionDeniedAlert, LatLng, } from '../../../services/locationServices'; @@ -29,6 +30,8 @@ export const SetLocationScreen: React.FC = () => { const styles = getStyles(colors); const navigation = useNavigation(); const mapRef = useRef(null); + // Guard: never call setState after the component has unmounted + const isMounted = useRef(true); const [coords, setCoords] = useState(DEFAULT_LOCATION); const [address, setAddress] = useState('Fetching your location…'); @@ -38,27 +41,43 @@ export const SetLocationScreen: React.FC = () => { // Fetch current location on mount // ------------------------------------------------------------------ const fetchCurrentLocation = useCallback(async () => { + if (!isMounted.current) return; setLoading(true); try { console.log('--------------'); const result = await getCurrentLocationWithAddress(); console.log('result', result); + if (!isMounted.current) return; setCoords(result.coords); setAddress(result.address); // Animate the map to the user's location mapRef.current?.animateToRegion({ ...result.coords, ...DELTA }, 600); - } catch { - // Permission denied or GPS unavailable — keep the default location - console.log('--------error------'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + if (message === 'Location permission denied') { + // Show the alert here — safe, outside the Promise chain + showPermissionDeniedAlert(); + } + console.log('--------error------', err); + if (!isMounted.current) return; setAddress('Could not determine location'); } finally { - setLoading(false); + if (isMounted.current) setLoading(false); } }, []); useEffect(() => { - fetchCurrentLocation(); + // Wait for the screen-transition animation to fully finish before + // calling PermissionsAndroid.request(). Calling it while the Activity + // is still transitioning causes a native crash on Android. + const task = InteractionManager.runAfterInteractions(() => { + fetchCurrentLocation(); + }); + return () => { + task.cancel(); + isMounted.current = false; + }; }, [fetchCurrentLocation]); // ------------------------------------------------------------------ @@ -83,8 +102,6 @@ export const SetLocationScreen: React.FC = () => { ref={mapRef} style={styles.map} initialRegion={{ ...DEFAULT_LOCATION, ...DELTA }} - showsUserLocation - showsMyLocationButton={true} onRegionChangeComplete={handleRegionChangeComplete} > { // Skip attaching token for auth endpoints (login, register, refresh) const publicPaths = [ - '/auth/login', - '/auth/register', + '/auth/otp/request', + '/auth/otp/verify', '/auth/refresh-token', ]; const isPublic = publicPaths.some(path => config.url?.includes(path)); @@ -68,7 +68,7 @@ axiosInstance.interceptors.request.use( // Debug logging (remove in production) if (__DEV__) { - console.log(`🚀 [API] ${config.method?.toUpperCase()} ${config.url}`); + // console.log(`🚀 [API] ${config.method?.toUpperCase()} ${config.url}`);s } return config; diff --git a/app/services/locationServices.ts b/app/services/locationServices.ts index 9b10f77..d13fbee 100644 --- a/app/services/locationServices.ts +++ b/app/services/locationServices.ts @@ -46,13 +46,21 @@ export const requestLocationPermission = async (): Promise => { } } - // Android + // Android — check first to avoid showing the dialog when already granted try { + const alreadyGranted = await PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, + ); + if (alreadyGranted) { + return true; + } + const granted = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, { title: 'Location Permission', - message: 'This app needs access to your location to set it on the map.', + message: + 'This app needs access to your location to set it on the map.', buttonNeutral: 'Ask Me Later', buttonNegative: 'Cancel', buttonPositive: 'OK', @@ -88,38 +96,47 @@ export const showPermissionDeniedAlert = (): void => { * Get the device's current GPS position. * Wraps the callback-based Geolocation API in a Promise. */ +/** + * Wraps Geolocation.getCurrentPosition in a Promise. + * Tries high-accuracy first; falls back to low-accuracy on failure. + */ +const getPositionOnce = (highAccuracy: boolean): Promise => + new Promise((resolve, reject) => { + Geolocation.getCurrentPosition( + (position: GeoPosition) => { + resolve({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + }, + (error: GeoError) => { + console.log('Geolocation error (highAccuracy=' + highAccuracy + '):', error); + reject(error); + }, + { + enableHighAccuracy: highAccuracy, + timeout: highAccuracy ? 5000 : 10000, + maximumAge: 10000, + forceRequestLocation: false, // avoid native dialog that can crash + showLocationDialog: true, + }, + ); + }); + export const getCurrentPosition = async (): Promise => { + console.log('getCurrentPosition called'); try { - console.log('get current position'); - console.log('Geolocation:', Geolocation); - console.log('getCurrentPosition:', Geolocation?.getCurrentPosition); - - const location = await new Promise((resolve, reject) => { - Geolocation.getCurrentPosition( - (position: GeoPosition) => { - resolve({ - latitude: position.coords.latitude, - longitude: position.coords.longitude, - }); - }, - (error: GeoError) => { - console.log('Geolocation Error:', error); - reject(error); - }, - { - enableHighAccuracy: true, - timeout: 15000, - maximumAge: 10000, - forceRequestLocation: true, - showLocationDialog: true, - }, - ); - }); - - return location; - } catch (error) { - console.error('Failed to get current position:', error); - throw error; + // First attempt: high accuracy (GPS) + return await getPositionOnce(true); + } catch (highAccuracyError) { + console.warn('High-accuracy location failed, retrying with low accuracy…', highAccuracyError); + try { + // Fallback: network / cell-tower accuracy + return await getPositionOnce(false); + } catch (lowAccuracyError) { + console.error('Failed to get current position:', lowAccuracyError); + throw lowAccuracyError; + } } }; @@ -146,9 +163,38 @@ export const reverseGeocode = async (coords: LatLng): Promise => { return data.results[0].formatted_address; } + console.warn('Google Maps Geocoding failed:', data.status, data.error_message || ''); + + // Fallback to OpenStreetMap Nominatim + const osmUrl = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${coords.latitude}&lon=${coords.longitude}`; + const osmResponse = await fetch(osmUrl, { + headers: { + 'User-Agent': 'SGDeliveryCustomerApp/1.0', + }, + }); + const osmData = await osmResponse.json(); + if (osmData && osmData.display_name) { + return osmData.display_name; + } + return 'Address not found'; } catch (error) { console.log('reverseGeocode error', error); + try { + // Fallback to OpenStreetMap Nominatim on catch + const osmUrl = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${coords.latitude}&lon=${coords.longitude}`; + const osmResponse = await fetch(osmUrl, { + headers: { + 'User-Agent': 'SGDeliveryCustomerApp/1.0', + }, + }); + const osmData = await osmResponse.json(); + if (osmData && osmData.display_name) { + return osmData.display_name; + } + } catch (osmError) { + console.log('OSM fallback error', osmError); + } return 'Unable to fetch address'; } }; @@ -166,9 +212,10 @@ export const reverseGeocode = async (coords: LatLng): Promise => { export const getCurrentLocationWithAddress = async (): Promise => { const hasPermission = await requestLocationPermission(); - + console.log('hasPermission', hasPermission); if (!hasPermission) { - showPermissionDeniedAlert(); + // Alert shown by the caller so it doesn't fire inside a Promise chain + // on mount (which can cause a crash on some Android versions). throw new Error('Location permission denied'); } diff --git a/app/store/commonreducers/auth/reducer.ts b/app/store/commonreducers/auth/reducer.ts index bfcdf99..2f12838 100644 --- a/app/store/commonreducers/auth/reducer.ts +++ b/app/store/commonreducers/auth/reducer.ts @@ -1,129 +1,66 @@ -import { createAction, createReducer } from '@reduxjs/toolkit'; -import { User, Location } from '../../../interfaces'; -import { loginWithPhone, verifyOtp, updateProfile, logoutUser } from './thunk'; +import { createReducer } from '@reduxjs/toolkit'; +import { LoginResponse, VerifyOtpResponse } from '../../../interfaces'; +import { loginWithPhone, verifyOtp } from './thunk'; +import { User } from '@interfaces/auth'; // ─── State ─────────────────────────────────────────────────────────────────── export interface AuthState { + loginData: LoginResponse | null; user: User | null; - isAuthenticated: boolean; - isLoading: boolean; - onboardingComplete: boolean; + accessToken: string | null; + refreshToken: string | null; + isNewUser: boolean | null; error: string | null; + isLoading: boolean; } const initialState: AuthState = { + loginData: null, user: null, - isAuthenticated: false, + accessToken: null, + refreshToken: null, + isNewUser: null, isLoading: false, - onboardingComplete: false, error: null, }; -// ─── Sync Actions ──────────────────────────────────────────────────────────── - -export const setUser = createAction('auth/setUser'); -export const setLocation = createAction('auth/setLocation'); -export const completeProfile = createAction<{ - name: string; - email: string; - locationLabels: string[]; -}>('auth/completeProfile'); -export const completeOnboarding = createAction('auth/completeOnboarding'); -export const logout = createAction('auth/logout'); -export const clearError = createAction('auth/clearError'); - // ─── Reducer ───────────────────────────────────────────────────────────────── const authReducer = createReducer(initialState, builder => { builder - // ── Sync Actions ─────────────────────────────────────────────────────── - .addCase(setUser, (state, action) => { - state.user = action.payload; - }) - .addCase(setLocation, (state, _action) => { - if (state.user) { - state.user = { ...state.user }; - } - }) - .addCase(completeProfile, (state, action) => { - if (state.user) { - state.user = { - ...state.user, - name: action.payload.name, - email: action.payload.email, - locationLabels: action.payload.locationLabels, - }; - } - }) - .addCase(completeOnboarding, state => { - state.onboardingComplete = true; - }) - .addCase(logout, state => { - state.user = null; - state.isAuthenticated = false; - state.onboardingComplete = false; - state.error = null; - }) - .addCase(clearError, state => { - state.error = null; - }) - - // ── loginWithPhone ───────────────────────────────────────────────────── + // Login .addCase(loginWithPhone.pending, state => { state.isLoading = true; state.error = null; }) - .addCase(loginWithPhone.fulfilled, state => { + .addCase(loginWithPhone.fulfilled, (state, action) => { + state.loginData = action.payload; state.isLoading = false; + state.error = null; }) .addCase(loginWithPhone.rejected, (state, action) => { state.isLoading = false; - state.error = (action.payload as string) || 'Login failed'; + state.error = action.payload as string; }) - // ── verifyOtp ────────────────────────────────────────────────────────── + // Verify OTP .addCase(verifyOtp.pending, state => { state.isLoading = true; state.error = null; }) .addCase(verifyOtp.fulfilled, (state, action) => { + const { user, accessToken, refreshToken, isNewUser } = action.payload; + state.user = user; + state.accessToken = accessToken; + state.refreshToken = refreshToken; + state.isNewUser = isNewUser; state.isLoading = false; - state.isAuthenticated = true; - state.user = action.payload.user; + state.error = null; }) .addCase(verifyOtp.rejected, (state, action) => { state.isLoading = false; - state.error = (action.payload as string) || 'OTP verification failed'; - }) - - // ── updateProfile ────────────────────────────────────────────────────── - .addCase(updateProfile.pending, state => { - state.isLoading = true; - state.error = null; - }) - .addCase(updateProfile.fulfilled, (state, action) => { - state.isLoading = false; - state.user = action.payload.user; - }) - .addCase(updateProfile.rejected, (state, action) => { - state.isLoading = false; - state.error = (action.payload as string) || 'Profile update failed'; - }) - - // ── logoutUser ───────────────────────────────────────────────────────── - .addCase(logoutUser.fulfilled, state => { - state.user = null; - state.isAuthenticated = false; - state.onboardingComplete = false; - state.error = null; - }) - .addCase(logoutUser.rejected, state => { - // Still clear state even if API fails (tokens already cleared in thunk) - state.user = null; - state.isAuthenticated = false; - state.onboardingComplete = false; - state.error = null; + state.error = action.payload as string; }); }); diff --git a/app/store/commonreducers/auth/thunk.ts b/app/store/commonreducers/auth/thunk.ts index fe654de..66fbe2a 100644 --- a/app/store/commonreducers/auth/thunk.ts +++ b/app/store/commonreducers/auth/thunk.ts @@ -1,18 +1,23 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { loginApi, verifyOtpApi, updateProfileApi, logoutApi } from '../../../api/authApi'; +import { + loginApi, + verifyOtpApi, + updateProfileApi, + logoutApi, +} from '../../../api/authApi'; import { tokenManager } from '../../../services/apiClient'; // ─── Login ─────────────────────────────────────────────────────────────────── export const loginWithPhone = createAsyncThunk( 'auth/loginWithPhone', - async (mobileNumber: string, { rejectWithValue }) => { + async (phone: string, { rejectWithValue }) => { try { - const response = await loginApi(mobileNumber); + const response = await loginApi(phone); + console.log(response); return response; } catch (error: unknown) { - const message = - error instanceof Error ? error.message : 'Login failed'; + const message = error instanceof Error ? error.message : 'Login failed'; return rejectWithValue(message); } }, @@ -23,11 +28,11 @@ export const loginWithPhone = createAsyncThunk( export const verifyOtp = createAsyncThunk( 'auth/verifyOtp', async ( - { mobileNumber, otp }: { mobileNumber: string; otp: string }, + { phone, code, role }: { phone: string; code: string; role: string }, { rejectWithValue }, ) => { try { - const response = await verifyOtpApi(mobileNumber, otp); + const response = await verifyOtpApi(phone, code, role); // Persist tokens on successful verification if (response.accessToken && response.refreshToken) { @@ -77,8 +82,7 @@ export const logoutUser = createAsyncThunk( } catch (error: unknown) { // Clear tokens even if API call fails await tokenManager.clearTokens(); - const message = - error instanceof Error ? error.message : 'Logout failed'; + const message = error instanceof Error ? error.message : 'Logout failed'; return rejectWithValue(message); } }, diff --git a/babel.config.js b/babel.config.js index 4bfa3f7..3fcb3d5 100644 --- a/babel.config.js +++ b/babel.config.js @@ -9,6 +9,10 @@ module.exports = { '@components': './app/components', '@features/screens': './app/features/screens', '@theme': './app/theme', + '@hooks': './app/hooks', + '@navigation': './app/navigation', + '@services': './app/services', + '@store': './app/store', }, }, ], diff --git a/index.js b/index.js index 3187351..06002ff 100644 --- a/index.js +++ b/index.js @@ -2,4 +2,8 @@ import { AppRegistry } from 'react-native'; import App from './app/App'; import { name as appName } from './app.json'; +if (__DEV__) { + require('./ReactotronConfig'); +} + AppRegistry.registerComponent(appName, () => App); diff --git a/package.json b/package.json index 320ac4b..475c668 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-native-svg": "^15.15.5", "react-native-worklets": "^0.10.0", "react-redux": "^9.3.0", + "reactotron-react-native": "^5.2.0", "redux-persist": "^6.0.0", "redux-thunk": "^3.1.0" }, diff --git a/tsconfig.json b/tsconfig.json index 0ec4211..c59ec17 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,17 @@ "@features/screens": ["./app/features/screens"], "@features/screens/*": ["./app/features/screens/*"], "@theme": ["./app/theme"], - "@theme/*": ["./app/theme/*"] + "@theme/*": ["./app/theme/*"], + "@api": ["./app/api"], + "@api/*": ["./app/api/*"], + "@store": ["./app/store"], + "@store/*": ["./app/store/*"], + "@interfaces": ["./app/interfaces"], + "@interfaces/*": ["./app/interfaces/*"], + "@navigation": ["./app/navigation"], + "@navigation/*": ["./app/navigation/*"], + "@hooks": ["./app/hooks"], + "@hooks/*": ["./app/hooks/*"] } }, "include": ["**/*.ts", "**/*.tsx"], diff --git a/yarn.lock b/yarn.lock index 7c945fe..35bcf49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5290,6 +5290,11 @@ minipass@^4.2.4: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" @@ -5935,6 +5940,27 @@ react@19.2.3: resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8" integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== +reactotron-core-client@2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/reactotron-core-client/-/reactotron-core-client-2.10.0.tgz#494e6e56bb018774a0a63c797d48b53ac6986256" + integrity sha512-mOzkc1Rb4sN98OLY/frkP0e1A60bTFjuXUgG8D4m+pwCcjVCWZupdSFV8c5+gqcZBnreoVR20/ko6D8X/7KPvg== + dependencies: + reactotron-core-contract "0.4.0" + +reactotron-core-contract@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/reactotron-core-contract/-/reactotron-core-contract-0.4.0.tgz#7227fe19be6b564c083318798bdf88af943ca995" + integrity sha512-o7m4mSzaUM/IGDc1dBfe0q6rgSi7mZCP7FCs3kHF7AJQwXBOKx/awjVaeNVqz+w6ADiw7X4dtDdQvEROAXdD3w== + +reactotron-react-native@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/reactotron-react-native/-/reactotron-react-native-5.2.0.tgz#e722f92860951b383d17400115825aa3a3b10502" + integrity sha512-7Jd47Eti4wvyn1p5MsQ4mEg2mGG8B5HIZHASjK5RKVfOajEP9Sf4Vume9URG6inB/UQWGpBP6ahCGqcSo4qn3w== + dependencies: + mitt "^3.0.1" + reactotron-core-client "2.10.0" + reactotron-core-contract "0.4.0" + readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"