feat: implement core screens and authentication infrastructure including cart, login, and profile flows

This commit is contained in:
Tamojit Biswas 2026-07-06 10:14:58 +05:30
parent 44f6050e60
commit 7a0fd59892
25 changed files with 486 additions and 261 deletions

5
ReactotronConfig.js Normal file
View File

@ -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!

View File

@ -6,6 +6,20 @@ buildscript {
targetSdkVersion = 36 targetSdkVersion = 36
ndkVersion = "27.1.12297006" ndkVersion = "27.1.12297006"
kotlinVersion = "2.1.20" 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 { repositories {
google() google()
@ -19,3 +33,13 @@ buildscript {
} }
apply plugin: "com.facebook.react.rootproject" 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"
}
}
}

View File

@ -1,20 +1,8 @@
import { apiClient } from '../services/apiClient'; import { apiClient } from '../services/apiClient';
import { User } from '../interfaces'; import { LoginResponse, User, VerifyOtpResponse } from '../interfaces';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
export interface LoginResponse {
success: boolean;
message: string;
}
export interface VerifyOtpResponse {
success: boolean;
user: User;
accessToken: string;
refreshToken: string;
}
export interface UpdateProfileResponse { export interface UpdateProfileResponse {
success: boolean; success: boolean;
user: User; user: User;
@ -26,11 +14,11 @@ export interface LogoutResponse {
// ─── Endpoints ─────────────────────────────────────────────────────────────── // ─── Endpoints ───────────────────────────────────────────────────────────────
export const loginApi = (mobileNumber: string) => export const loginApi = (phone: string) =>
apiClient.post<LoginResponse>('/auth/login', { mobileNumber }); apiClient.post<LoginResponse>('/auth/otp/request', { phone });
export const verifyOtpApi = (mobileNumber: string, otp: string) => export const verifyOtpApi = (phone: string, code: string, role: string) =>
apiClient.post<VerifyOtpResponse>('/auth/verify-otp', { mobileNumber, otp }); apiClient.post<VerifyOtpResponse>('/auth/otp/verify', { phone, code, role });
export const updateProfileApi = (data: { export const updateProfileApi = (data: {
name: string; name: string;

View File

@ -9,7 +9,8 @@ import {
import { getStyles } from './loginScreen.styles'; import { getStyles } from './loginScreen.styles';
import { CustomInput, PrimaryButton } from '@components'; import { CustomInput, PrimaryButton } from '@components';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { useLoginScreen } from '../../../hooks'; import { useLoginScreen } from './hooks/useLoginScreen';
import { RootState, useAppSelector } from '@store';
export const LoginScreen: React.FC = () => { export const LoginScreen: React.FC = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@ -18,6 +19,8 @@ export const LoginScreen: React.FC = () => {
const { mobileNumber, error, onChangeMobileNumber, handleLogin } = const { mobileNumber, error, onChangeMobileNumber, handleLogin } =
useLoginScreen(); useLoginScreen();
const { isLoading } = useAppSelector((state: RootState) => state.auth);
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.container} style={styles.container}
@ -69,9 +72,10 @@ export const LoginScreen: React.FC = () => {
)} )}
<PrimaryButton <PrimaryButton
title="Get OTP" title={isLoading ? 'Loading...' : 'Get OTP'}
onPress={handleLogin} onPress={handleLogin}
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
disabled={isLoading}
/> />
</View> </View>

View File

@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
import { AuthStackParamList } from 'app/navigation/authStack'; import { AuthStackParamList } from 'app/navigation/authStack';
import { loginWithPhone, useAppDispatch } from '../store'; import { loginWithPhone, useAppDispatch } from '@store';
type LoginNavProp = StackNavigationProp<AuthStackParamList, 'LoginScreen'>; type LoginNavProp = StackNavigationProp<AuthStackParamList, 'LoginScreen'>;

View File

@ -9,10 +9,10 @@ import { StackNavigationProp } from '@react-navigation/stack';
import { getStyles } from './accountScreen.styles'; import { getStyles } from './accountScreen.styles';
import { Header, PrimaryButton } from '@components'; import { Header, PrimaryButton } from '@components';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch';
import { logout } from '../../../store/commonreducers/auth'; import { logout } from '../../../store/commonreducers/auth';
import { AppStackParamList } from '../../../navigation/appStack'; import { AppStackParamList } from '../../../navigation/appStack';
import { MainTabParamList } from '../../../navigation/mainTabNavigator'; import { MainTabParamList } from '../../../navigation/mainTabNavigator';
import { useAppDispatch, useAppSelector } from '@store';
type AccountNavProp = CompositeNavigationProp< type AccountNavProp = CompositeNavigationProp<
BottomTabNavigationProp<MainTabParamList, 'AccountScreen'>, BottomTabNavigationProp<MainTabParamList, 'AccountScreen'>,

View File

@ -11,7 +11,6 @@ import { StackNavigationProp } from '@react-navigation/stack';
import { getStyles } from './cartScreen.styles'; import { getStyles } from './cartScreen.styles';
import { Header, QuantitySelector, PrimaryButton } from '@components'; import { Header, QuantitySelector, PrimaryButton } from '@components';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch';
import { import {
removeItem, removeItem,
applyCoupon, applyCoupon,
@ -19,6 +18,7 @@ import {
selectCartTotal, selectCartTotal,
} from '../../../store/commonreducers/cart'; } from '../../../store/commonreducers/cart';
import { AppStackParamList } from '../../../navigation/appStack'; import { AppStackParamList } from '../../../navigation/appStack';
import { useAppDispatch, useAppSelector } from '@store';
type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>; type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>;

View File

@ -12,9 +12,10 @@ import { StackNavigationProp } from '@react-navigation/stack';
import { getStyles } from './completeProfileScreen.styles'; import { getStyles } from './completeProfileScreen.styles';
import { CustomInput, PrimaryButton } from '@components'; import { CustomInput, PrimaryButton } from '@components';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { completeProfile } from '../../../store/commonreducers/auth'; import { completeProfile } from '../../../store/commonreducers/auth';
import { AuthStackParamList } from '../../../navigation/authStack'; import { AuthStackParamList } from '../../../navigation/authStack';
import { useAppDispatch } from '@store';
type NavProp = StackNavigationProp<AuthStackParamList, 'CompleteProfileScreen'>; type NavProp = StackNavigationProp<AuthStackParamList, 'CompleteProfileScreen'>;
@ -61,7 +62,7 @@ export const CompleteProfileScreen: React.FC = () => {
<Text style={styles.labelText}>Location Label</Text> <Text style={styles.labelText}>Location Label</Text>
<View style={styles.labelRow}> <View style={styles.labelRow}>
{LOCATION_LABELS.map((label) => ( {LOCATION_LABELS.map(label => (
<TouchableOpacity <TouchableOpacity
key={label} key={label}
style={[ style={[

View File

@ -3,8 +3,9 @@ import { View, Text } from 'react-native';
import { getStyles } from './onboardingCompleteScreen.styles'; import { getStyles } from './onboardingCompleteScreen.styles';
import { PrimaryButton } from '@components'; import { PrimaryButton } from '@components';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { completeOnboarding } from '../../../store/commonreducers/auth'; import { completeOnboarding } from '../../../store/commonreducers/auth';
import { useAppDispatch } from '@store';
export const OnboardingCompleteScreen: React.FC = () => { export const OnboardingCompleteScreen: React.FC = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@ -25,10 +26,7 @@ export const OnboardingCompleteScreen: React.FC = () => {
</Text> </Text>
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton <PrimaryButton title="Explore Now" onPress={handleExplore} />
title="Explore Now"
onPress={handleExplore}
/>
</View> </View>
</View> </View>
); );

View File

@ -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<AuthStackParamList, 'OtpScreen'>;
type OtpRouteProp = RouteProp<AuthStackParamList, 'OtpScreen'>;
const OTP_LENGTH = 6;
const RESEND_TIMER = 30;
export const useOtpScreen = () => {
const navigation = useNavigation<OtpNavProp>();
const route = useRoute<OtpRouteProp>();
const dispatch = useAppDispatch();
const { mobileNumber } = route.params;
const { loginData, user } = useAppSelector((state: RootState) => state.auth);
const [otp, setOtp] = useState<string[]>(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,
};
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { import {
View, View,
Text, Text,
@ -6,71 +6,27 @@ import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { getStyles } from './otpScreen.styles'; import { getStyles } from './otpScreen.styles';
import { PrimaryButton } from '@components'; import { PrimaryButton } from '@components';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { useAppDispatch } from '../../../hooks/useAppDispatch'; import { useOtpScreen } from './hooks/useOtpScreen';
import { verifyOtp } from '../../../store/commonreducers/auth';
import { AuthStackParamList } from '../../../navigation/authStack';
type OtpNavProp = StackNavigationProp<AuthStackParamList, 'OtpScreen'>;
type OtpRouteProp = RouteProp<AuthStackParamList, 'OtpScreen'>;
const OTP_LENGTH = 6;
const RESEND_TIMER = 30;
export const OtpScreen: React.FC = () => { export const OtpScreen: React.FC = () => {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const navigation = useNavigation<OtpNavProp>();
const route = useRoute<OtpRouteProp>();
const dispatch = useAppDispatch();
const { mobileNumber } = route.params;
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill('')); const {
const [activeIndex, setActiveIndex] = useState(0); mobileNumber,
const [timer, setTimer] = useState(RESEND_TIMER); otp,
const [isLoading, setIsLoading] = useState(false); activeIndex,
timer,
useEffect(() => { isLoading,
if (timer > 0) { otpLength,
const interval = setInterval(() => setTimer((t) => t - 1), 1000); isOtpComplete,
return () => clearInterval(interval); handleKeyPress,
} handleVerify,
}, [timer]); handleResend,
} = useOtpScreen();
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');
};
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
@ -79,12 +35,40 @@ export const OtpScreen: React.FC = () => {
> >
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.title}>Verify OTP</Text> <Text style={styles.title}>Verify OTP</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>Code sent to {mobileNumber}</Text>
Code sent to {mobileNumber}
</Text>
<View style={styles.otpContainer}> <View style={styles.otpContainer}>
{otp.map((digit, index) => ( {otp.map(
(
digit:
| string
| number
| bigint
| boolean
| React.ReactElement<
unknown,
string | React.JSXElementConstructor<any>
>
| Iterable<React.ReactNode>
| React.ReactPortal
| Promise<
| string
| number
| bigint
| boolean
| React.ReactPortal
| React.ReactElement<
unknown,
string | React.JSXElementConstructor<any>
>
| Iterable<React.ReactNode>
| null
| undefined
>
| null
| undefined,
index: React.Key | null | undefined,
) => (
<View <View
key={index} key={index}
style={[ style={[
@ -95,11 +79,12 @@ export const OtpScreen: React.FC = () => {
> >
<Text style={styles.otpCellText}>{digit}</Text> <Text style={styles.otpCellText}>{digit}</Text>
</View> </View>
))} ),
)}
</View> </View>
<View style={styles.keypad}> <View style={styles.keypad}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => ( {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => (
<TouchableOpacity <TouchableOpacity
key={num} key={num}
style={styles.key} style={styles.key}
@ -129,14 +114,7 @@ export const OtpScreen: React.FC = () => {
{timer > 0 ? ( {timer > 0 ? (
<Text style={styles.timerText}>Resend code in {timer}s</Text> <Text style={styles.timerText}>Resend code in {timer}s</Text>
) : ( ) : (
<TouchableOpacity <TouchableOpacity onPress={handleResend} activeOpacity={0.7}>
onPress={() => {
setTimer(RESEND_TIMER);
setOtp(Array(OTP_LENGTH).fill(''));
setActiveIndex(0);
}}
activeOpacity={0.7}
>
<Text style={styles.resendText}>Resend OTP</Text> <Text style={styles.resendText}>Resend OTP</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@ -145,7 +123,7 @@ export const OtpScreen: React.FC = () => {
title="Verify" title="Verify"
onPress={handleVerify} onPress={handleVerify}
isLoading={isLoading} isLoading={isLoading}
disabled={otp.join('').length !== OTP_LENGTH} disabled={!isOtpComplete}
style={{ marginTop: 24 }} style={{ marginTop: 24 }}
/> />
</View> </View>

View File

@ -12,11 +12,14 @@ import { StackNavigationProp } from '@react-navigation/stack';
import { getStyles } from './providerDetailsScreen.styles'; import { getStyles } from './providerDetailsScreen.styles';
import { CatalogItemRow } from '@components'; import { CatalogItemRow } from '@components';
import { useAppTheme } from '@theme'; import { useAppTheme } from '@theme';
import { useAppDispatch, useAppSelector } from '../../../hooks/useAppDispatch';
import { addItem } from '../../../store/commonreducers/cart'; import { addItem } from '../../../store/commonreducers/cart';
import { getProviderCatalogApi, getProvidersApi } from '../../../api/deliveryApi'; import {
getProviderCatalogApi,
getProvidersApi,
} from '../../../api/deliveryApi';
import { CatalogItem, Provider } from '../../../interfaces'; import { CatalogItem, Provider } from '../../../interfaces';
import { AppStackParamList } from '../../../navigation/appStack'; import { AppStackParamList } from '../../../navigation/appStack';
import { useAppDispatch, useAppSelector } from '@store';
type ProviderDetailsNavProp = StackNavigationProp< type ProviderDetailsNavProp = StackNavigationProp<
AppStackParamList, AppStackParamList,

View File

@ -3,8 +3,8 @@ import {
View, View,
Text, Text,
TouchableOpacity, TouchableOpacity,
Platform,
ActivityIndicator, ActivityIndicator,
InteractionManager,
} from 'react-native'; } from 'react-native';
import MapView, { Marker, Region } from 'react-native-maps'; import MapView, { Marker, Region } from 'react-native-maps';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@ -17,6 +17,7 @@ import {
DEFAULT_LOCATION, DEFAULT_LOCATION,
getCurrentLocationWithAddress, getCurrentLocationWithAddress,
reverseGeocode, reverseGeocode,
showPermissionDeniedAlert,
LatLng, LatLng,
} from '../../../services/locationServices'; } from '../../../services/locationServices';
@ -29,6 +30,8 @@ export const SetLocationScreen: React.FC = () => {
const styles = getStyles(colors); const styles = getStyles(colors);
const navigation = useNavigation<NavProp>(); const navigation = useNavigation<NavProp>();
const mapRef = useRef<MapView>(null); const mapRef = useRef<MapView>(null);
// Guard: never call setState after the component has unmounted
const isMounted = useRef(true);
const [coords, setCoords] = useState<LatLng>(DEFAULT_LOCATION); const [coords, setCoords] = useState<LatLng>(DEFAULT_LOCATION);
const [address, setAddress] = useState('Fetching your location…'); const [address, setAddress] = useState('Fetching your location…');
@ -38,27 +41,43 @@ export const SetLocationScreen: React.FC = () => {
// Fetch current location on mount // Fetch current location on mount
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const fetchCurrentLocation = useCallback(async () => { const fetchCurrentLocation = useCallback(async () => {
if (!isMounted.current) return;
setLoading(true); setLoading(true);
try { try {
console.log('--------------'); console.log('--------------');
const result = await getCurrentLocationWithAddress(); const result = await getCurrentLocationWithAddress();
console.log('result', result); console.log('result', result);
if (!isMounted.current) return;
setCoords(result.coords); setCoords(result.coords);
setAddress(result.address); setAddress(result.address);
// Animate the map to the user's location // Animate the map to the user's location
mapRef.current?.animateToRegion({ ...result.coords, ...DELTA }, 600); mapRef.current?.animateToRegion({ ...result.coords, ...DELTA }, 600);
} catch { } catch (err: unknown) {
// Permission denied or GPS unavailable — keep the default location const message = err instanceof Error ? err.message : '';
console.log('--------error------'); 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'); setAddress('Could not determine location');
} finally { } finally {
setLoading(false); if (isMounted.current) setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
// 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(); fetchCurrentLocation();
});
return () => {
task.cancel();
isMounted.current = false;
};
}, [fetchCurrentLocation]); }, [fetchCurrentLocation]);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -83,8 +102,6 @@ export const SetLocationScreen: React.FC = () => {
ref={mapRef} ref={mapRef}
style={styles.map} style={styles.map}
initialRegion={{ ...DEFAULT_LOCATION, ...DELTA }} initialRegion={{ ...DEFAULT_LOCATION, ...DELTA }}
showsUserLocation
showsMyLocationButton={true}
onRegionChangeComplete={handleRegionChangeComplete} onRegionChangeComplete={handleRegionChangeComplete}
> >
<Marker <Marker

View File

@ -1 +0,0 @@
export * from './useLoginScreen';

60
app/interfaces/auth.ts Normal file
View File

@ -0,0 +1,60 @@
export interface LoginResponse {
message: string;
phone: string;
code: string;
}
export interface VerifyOtpResponse {
message: string;
isNewUser: boolean;
accessToken: string;
refreshToken: string;
user: User;
}
export interface User {
id: string;
roleId: string;
roleEnum: RoleEnum;
name: string | null;
email: string | null;
phone: string;
profileImage: string | null;
status: UserStatus;
mfaEnabled: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
isDeleted: boolean;
lastLogoutAt: string | null;
loginAt: string;
refreshTokenId: string;
refreshTokenHash: string;
refreshTokenExpiresAt: string;
categoryPreferences: CategoryPreference[];
gender: Gender | null;
dateOfBirth: string | null;
role: Role;
merchants: Merchant[];
}
export interface Role {
id: string;
code: string;
scope: string;
isSystem: boolean;
}
export interface CategoryPreference {
// Extend when backend starts returning data
}
export interface Merchant {
// Extend when backend starts returning data
}
export type RoleEnum = 'CUSTOMER';
export type UserStatus = 'ONBOARDING' | 'ACTIVE' | 'INACTIVE' | 'BLOCKED';
export type Gender = 'MALE' | 'FEMALE' | 'OTHER';

View File

@ -43,7 +43,12 @@ export interface CartItem {
quantity: number; quantity: number;
} }
export type OrderStatus = 'placed' | 'confirmed' | 'dispatched' | 'delivered' | 'cancelled'; export type OrderStatus =
| 'placed'
| 'confirmed'
| 'dispatched'
| 'delivered'
| 'cancelled';
export interface Order { export interface Order {
id: string; id: string;
@ -83,3 +88,5 @@ export interface PaymentMethod {
label: string; label: string;
icon?: string; icon?: string;
} }
export * from './auth';

View File

@ -15,7 +15,7 @@ const STORAGE_KEYS = {
} as const; } as const;
// ─── Config ────────────────────────────────────────────────────────────────── // ─── Config ──────────────────────────────────────────────────────────────────
const BASE_URL = 'https://api.example.com/v1'; // TODO: replace with your actual base URL const BASE_URL = 'https://a84e-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
// ─── Token Helpers ─────────────────────────────────────────────────────────── // ─── Token Helpers ───────────────────────────────────────────────────────────
export const tokenManager = { export const tokenManager = {
@ -53,8 +53,8 @@ axiosInstance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => { async (config: InternalAxiosRequestConfig) => {
// Skip attaching token for auth endpoints (login, register, refresh) // Skip attaching token for auth endpoints (login, register, refresh)
const publicPaths = [ const publicPaths = [
'/auth/login', '/auth/otp/request',
'/auth/register', '/auth/otp/verify',
'/auth/refresh-token', '/auth/refresh-token',
]; ];
const isPublic = publicPaths.some(path => config.url?.includes(path)); const isPublic = publicPaths.some(path => config.url?.includes(path));
@ -68,7 +68,7 @@ axiosInstance.interceptors.request.use(
// Debug logging (remove in production) // Debug logging (remove in production)
if (__DEV__) { if (__DEV__) {
console.log(`🚀 [API] ${config.method?.toUpperCase()} ${config.url}`); // console.log(`🚀 [API] ${config.method?.toUpperCase()} ${config.url}`);s
} }
return config; return config;

View File

@ -46,13 +46,21 @@ export const requestLocationPermission = async (): Promise<boolean> => {
} }
} }
// Android // Android — check first to avoid showing the dialog when already granted
try { try {
const alreadyGranted = await PermissionsAndroid.check(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
);
if (alreadyGranted) {
return true;
}
const granted = await PermissionsAndroid.request( const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{ {
title: 'Location Permission', 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', buttonNeutral: 'Ask Me Later',
buttonNegative: 'Cancel', buttonNegative: 'Cancel',
buttonPositive: 'OK', buttonPositive: 'OK',
@ -88,13 +96,12 @@ export const showPermissionDeniedAlert = (): void => {
* Get the device's current GPS position. * Get the device's current GPS position.
* Wraps the callback-based Geolocation API in a Promise. * Wraps the callback-based Geolocation API in a Promise.
*/ */
export const getCurrentPosition = async (): Promise<LatLng> => { /**
try { * Wraps Geolocation.getCurrentPosition in a Promise.
console.log('get current position'); * Tries high-accuracy first; falls back to low-accuracy on failure.
console.log('Geolocation:', Geolocation); */
console.log('getCurrentPosition:', Geolocation?.getCurrentPosition); const getPositionOnce = (highAccuracy: boolean): Promise<LatLng> =>
new Promise<LatLng>((resolve, reject) => {
const location = await new Promise<LatLng>((resolve, reject) => {
Geolocation.getCurrentPosition( Geolocation.getCurrentPosition(
(position: GeoPosition) => { (position: GeoPosition) => {
resolve({ resolve({
@ -103,23 +110,33 @@ export const getCurrentPosition = async (): Promise<LatLng> => {
}); });
}, },
(error: GeoError) => { (error: GeoError) => {
console.log('Geolocation Error:', error); console.log('Geolocation error (highAccuracy=' + highAccuracy + '):', error);
reject(error); reject(error);
}, },
{ {
enableHighAccuracy: true, enableHighAccuracy: highAccuracy,
timeout: 15000, timeout: highAccuracy ? 5000 : 10000,
maximumAge: 10000, maximumAge: 10000,
forceRequestLocation: true, forceRequestLocation: false, // avoid native dialog that can crash
showLocationDialog: true, showLocationDialog: true,
}, },
); );
}); });
return location; export const getCurrentPosition = async (): Promise<LatLng> => {
} catch (error) { console.log('getCurrentPosition called');
console.error('Failed to get current position:', error); try {
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<string> => {
return data.results[0].formatted_address; 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'; return 'Address not found';
} catch (error) { } catch (error) {
console.log('reverseGeocode error', 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'; return 'Unable to fetch address';
} }
}; };
@ -166,9 +212,10 @@ export const reverseGeocode = async (coords: LatLng): Promise<string> => {
export const getCurrentLocationWithAddress = export const getCurrentLocationWithAddress =
async (): Promise<LocationResult> => { async (): Promise<LocationResult> => {
const hasPermission = await requestLocationPermission(); const hasPermission = await requestLocationPermission();
console.log('hasPermission', hasPermission);
if (!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'); throw new Error('Location permission denied');
} }

View File

@ -1,129 +1,66 @@
import { createAction, createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { User, Location } from '../../../interfaces'; import { LoginResponse, VerifyOtpResponse } from '../../../interfaces';
import { loginWithPhone, verifyOtp, updateProfile, logoutUser } from './thunk'; import { loginWithPhone, verifyOtp } from './thunk';
import { User } from '@interfaces/auth';
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
export interface AuthState { export interface AuthState {
loginData: LoginResponse | null;
user: User | null; user: User | null;
isAuthenticated: boolean; accessToken: string | null;
isLoading: boolean; refreshToken: string | null;
onboardingComplete: boolean; isNewUser: boolean | null;
error: string | null; error: string | null;
isLoading: boolean;
} }
const initialState: AuthState = { const initialState: AuthState = {
loginData: null,
user: null, user: null,
isAuthenticated: false, accessToken: null,
refreshToken: null,
isNewUser: null,
isLoading: false, isLoading: false,
onboardingComplete: false,
error: null, error: null,
}; };
// ─── Sync Actions ────────────────────────────────────────────────────────────
export const setUser = createAction<User>('auth/setUser');
export const setLocation = createAction<Location>('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 ───────────────────────────────────────────────────────────────── // ─── Reducer ─────────────────────────────────────────────────────────────────
const authReducer = createReducer(initialState, builder => { const authReducer = createReducer(initialState, builder => {
builder builder
// ── Sync Actions ─────────────────────────────────────────────────────── // Login
.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 ─────────────────────────────────────────────────────
.addCase(loginWithPhone.pending, state => { .addCase(loginWithPhone.pending, state => {
state.isLoading = true; state.isLoading = true;
state.error = null; state.error = null;
}) })
.addCase(loginWithPhone.fulfilled, state => { .addCase(loginWithPhone.fulfilled, (state, action) => {
state.loginData = action.payload;
state.isLoading = false; state.isLoading = false;
state.error = null;
}) })
.addCase(loginWithPhone.rejected, (state, action) => { .addCase(loginWithPhone.rejected, (state, action) => {
state.isLoading = false; state.isLoading = false;
state.error = (action.payload as string) || 'Login failed'; state.error = action.payload as string;
}) })
// ── verifyOtp ────────────────────────────────────────────────────────── // Verify OTP
.addCase(verifyOtp.pending, state => { .addCase(verifyOtp.pending, state => {
state.isLoading = true; state.isLoading = true;
state.error = null; state.error = null;
}) })
.addCase(verifyOtp.fulfilled, (state, action) => { .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.isLoading = false;
state.isAuthenticated = true; state.error = null;
state.user = action.payload.user;
}) })
.addCase(verifyOtp.rejected, (state, action) => { .addCase(verifyOtp.rejected, (state, action) => {
state.isLoading = false; state.isLoading = false;
state.error = (action.payload as string) || 'OTP verification failed'; state.error = action.payload as string;
})
// ── 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;
}); });
}); });

View File

@ -1,18 +1,23 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; 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'; import { tokenManager } from '../../../services/apiClient';
// ─── Login ─────────────────────────────────────────────────────────────────── // ─── Login ───────────────────────────────────────────────────────────────────
export const loginWithPhone = createAsyncThunk( export const loginWithPhone = createAsyncThunk(
'auth/loginWithPhone', 'auth/loginWithPhone',
async (mobileNumber: string, { rejectWithValue }) => { async (phone: string, { rejectWithValue }) => {
try { try {
const response = await loginApi(mobileNumber); const response = await loginApi(phone);
console.log(response);
return response; return response;
} catch (error: unknown) { } catch (error: unknown) {
const message = const message = error instanceof Error ? error.message : 'Login failed';
error instanceof Error ? error.message : 'Login failed';
return rejectWithValue(message); return rejectWithValue(message);
} }
}, },
@ -23,11 +28,11 @@ export const loginWithPhone = createAsyncThunk(
export const verifyOtp = createAsyncThunk( export const verifyOtp = createAsyncThunk(
'auth/verifyOtp', 'auth/verifyOtp',
async ( async (
{ mobileNumber, otp }: { mobileNumber: string; otp: string }, { phone, code, role }: { phone: string; code: string; role: string },
{ rejectWithValue }, { rejectWithValue },
) => { ) => {
try { try {
const response = await verifyOtpApi(mobileNumber, otp); const response = await verifyOtpApi(phone, code, role);
// Persist tokens on successful verification // Persist tokens on successful verification
if (response.accessToken && response.refreshToken) { if (response.accessToken && response.refreshToken) {
@ -77,8 +82,7 @@ export const logoutUser = createAsyncThunk(
} catch (error: unknown) { } catch (error: unknown) {
// Clear tokens even if API call fails // Clear tokens even if API call fails
await tokenManager.clearTokens(); await tokenManager.clearTokens();
const message = const message = error instanceof Error ? error.message : 'Logout failed';
error instanceof Error ? error.message : 'Logout failed';
return rejectWithValue(message); return rejectWithValue(message);
} }
}, },

View File

@ -9,6 +9,10 @@ module.exports = {
'@components': './app/components', '@components': './app/components',
'@features/screens': './app/features/screens', '@features/screens': './app/features/screens',
'@theme': './app/theme', '@theme': './app/theme',
'@hooks': './app/hooks',
'@navigation': './app/navigation',
'@services': './app/services',
'@store': './app/store',
}, },
}, },
], ],

View File

@ -2,4 +2,8 @@ import { AppRegistry } from 'react-native';
import App from './app/App'; import App from './app/App';
import { name as appName } from './app.json'; import { name as appName } from './app.json';
if (__DEV__) {
require('./ReactotronConfig');
}
AppRegistry.registerComponent(appName, () => App); AppRegistry.registerComponent(appName, () => App);

View File

@ -29,6 +29,7 @@
"react-native-svg": "^15.15.5", "react-native-svg": "^15.15.5",
"react-native-worklets": "^0.10.0", "react-native-worklets": "^0.10.0",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"reactotron-react-native": "^5.2.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-thunk": "^3.1.0" "redux-thunk": "^3.1.0"
}, },

View File

@ -9,7 +9,17 @@
"@features/screens": ["./app/features/screens"], "@features/screens": ["./app/features/screens"],
"@features/screens/*": ["./app/features/screens/*"], "@features/screens/*": ["./app/features/screens/*"],
"@theme": ["./app/theme"], "@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"], "include": ["**/*.ts", "**/*.tsx"],

View File

@ -5290,6 +5290,11 @@ minipass@^4.2.4:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== 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: mkdirp@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" 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" resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== 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: readable-stream@^3.4.0:
version "3.6.2" version "3.6.2"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"