Add login functionality

feature/app-setup
vinay kumar 2025-07-01 12:00:35 +05:30
parent 4168f1a4b7
commit 189f8bd673
10 changed files with 810 additions and 38 deletions

View File

@ -1,20 +1,20 @@
import React from "react";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import { Link, Tabs } from "expo-router";
import { Pressable } from "react-native";
import Colors from "@/constants/Colors";
import { useColorScheme } from "@/components/useColorScheme";
import { useClientOnlyValue } from "@/components/useClientOnlyValue";
import { Tabs } from "expo-router";
import { TAB_CONFIG } from "@/constants/config";
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
headerShown: useClientOnlyValue(false, true),
tabBarStyle: {
backgroundColor: "#252A34",
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
position: "absolute",
overflow: "hidden",
},
tabBarActiveTintColor: "#fff",
tabBarInactiveTintColor: "#aaa",
}}
>
{TAB_CONFIG.map(({ name, title, Icon, IconFilled }) => (

View File

@ -1,37 +1,129 @@
import { StyleSheet } from "react-native";
import { Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import EditScreenInfo from "@/components/EditScreenInfo";
import SemiCircleProgress from "../../components/home/SemiCircleProgress";
import { Text, View } from "@/components/Themed";
import { useNavigation } from "expo-router";
import { useLayoutEffect, useState } from "react";
import { StatusBar } from "expo-status-bar";
import Profile from "../../components/home/Profile";
import CustomerCareIcon from "../../assets/icons/customer-care.svg";
import ServiceReminderCard from "@/components/home/ServiceReminderCard";
import MetricCard from "@/components/home/MetricCard";
import PaymentDueCard from "@/components/home/PaymentDueCard";
import LocationMap from "@/components/home/LocationMap";
import BatteryWarrantyCard from "@/components/home/BatteryWarrantyCars";
import CustomerSupportModal from "@/components/home/CustomerSupportModal";
export default function TabOneScreen() {
export default function HomeScreen() {
const { t } = useTranslation();
const navigation = useNavigation();
const [isSupportModalVisible, setIsSupportModalVisible] = useState(false);
useLayoutEffect(() => {
navigation.setOptions({
headerTitle: () => (
<View style={styles.headerTitleContainer}>
<Text style={styles.title}>Yatri - NBX 600</Text>
<Text style={styles.subtitle}>DL253C3602</Text>
</View>
),
headerRight: () => (
<View style={styles.rightContainer}>
<Pressable
style={styles.iconContainer}
onPress={() => setIsSupportModalVisible(true)}
>
<CustomerCareIcon />
</Pressable>
<Profile username="Vishal" textSize={16} boxSize={32} />
</View>
),
});
}, [navigation]);
return (
<View style={styles.container}>
<Text style={styles.title}>{t("welcome")}</Text>
<Text style={styles.title}>{t("change-language")}</Text>
<View
style={styles.separator}
lightColor="#eee"
darkColor="rgba(255,255,255,0.1)"
<>
<StatusBar style="dark" />
<View style={styles.container}>
<ServiceReminderCard
type="info"
message="Service will be due in 3 days"
subMessage="Service Reminder"
/>
<View style={styles.iconContainer}>
<SemiCircleProgress progress={90} status={1} />
</View>
<View style={styles.metrics}>
<MetricCard heading="SoH" value="90%" />
<MetricCard heading="Total Distance" value="20009 km" />
</View>
<PaymentDueCard
label="Payment Due"
amount="$2,000"
onPress={() => {}}
/>
{/* <LocationMap latitude={12.9716} longitude={77.5946} /> */}
<BatteryWarrantyCard
totalWarrantyYears={8}
batteryPurchaseEpoch={Math.floor(
new Date("2023-06-30").getTime() / 1000
)}
/>
</View>
<CustomerSupportModal
visible={isSupportModalVisible}
onClose={() => setIsSupportModalVisible(false)}
/>
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
</>
);
}
const styles = StyleSheet.create({
metrics: {
flexDirection: "row",
justifyContent: "space-between",
backgroundColor: "#F3F5F8",
},
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#F3F5F8",
padding: 16,
gap: 16,
},
iconContainer: {
backgroundColor: "#fff",
},
headerTitleContainer: {
flexDirection: "column",
backgroundColor: "#fff",
},
title: {
fontSize: 20,
fontWeight: "bold",
fontSize: 14,
color: "#6B7280",
fontWeight: "500",
},
separator: {
marginVertical: 30,
height: 1,
width: "80%",
subtitle: {
fontSize: 18,
color: "#111827",
fontWeight: "700",
},
rightContainer: {
flexDirection: "row",
alignItems: "center",
paddingRight: 16,
gap: 8,
backgroundColor: "#fff",
},
badge: {
backgroundColor: "#FEE2E2",
borderRadius: 20,
width: 30,
height: 30,
justifyContent: "center",
alignItems: "center",
},
badgeText: {
color: "#DC2626",
fontWeight: "700",
},
});

View File

@ -6,8 +6,8 @@ import * as SplashScreen from "expo-splash-screen";
import { useEffect, useState } from "react";
import "react-native-reanimated";
import { I18nextProvider } from "react-i18next";
import { useColorScheme } from "@/components/useColorScheme";
import { Provider } from "react-redux";
import { store } from "@/store";
export { ErrorBoundary } from "expo-router";
@ -48,6 +48,7 @@ export default function RootLayout() {
useEffect(() => {
if (loaded && appIsReady) {
SplashScreen.hideAsync();
router.replace("/auth/login");
}
}, [loaded, appIsReady]);
@ -68,11 +69,13 @@ export default function RootLayout() {
function RootLayoutNav() {
return (
<I18nextProvider i18n={i18next}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
</Stack>
</I18nextProvider>
<Provider store={store}>
<I18nextProvider i18n={i18next}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
</Stack>
</I18nextProvider>
</Provider>
);
}

24
app/auth/_layout.tsx Normal file
View File

@ -0,0 +1,24 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { View, StyleSheet } from "react-native";
export default function AuthLayout() {
return (
<View style={styles.container}>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerShown: false,
animation: "fade",
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#F3F5F8",
},
});

213
app/auth/login.tsx Normal file
View File

@ -0,0 +1,213 @@
import React, { useEffect } from "react";
import {
Text,
View,
TextInput,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
Keyboard,
KeyboardAvoidingView,
StatusBar,
} from "react-native";
import { useRouter } from "expo-router";
import { Formik } from "formik";
import * as Yup from "yup";
import { sendOTP, clearSendOTPError } from "../../store/authSlice";
import { useDispatch } from "react-redux";
import { AppDispatch } from "../../store";
import { useSelector } from "react-redux";
import { RootState } from "../../store";
import { AUTH_STATUSES } from "@/constants/config";
// type VerifyOTPNavigationProp = StackNavigationProp<
// RootStackParamList,
// "VerifyOTP"
// >;
// import OTPInputView from "@twotalltotems/react-native-otp-input";
//handleblue => when input field looses focus (mark as touched)
// handleBlur marks the field as touched, when field looses focus
export default function WelcomeScreen() {
const {
status,
otpId,
phone: phoneNumber,
sendOTPError,
} = useSelector((state: RootState) => state.auth);
const dispatch = useDispatch<AppDispatch>();
const router = useRouter();
const phoneValidationSchema = Yup.object().shape({
phone: Yup.string()
.required("Phone number is required")
.matches(/^\d{10}$/, "Phone number must be exactly 10 digits"),
});
useEffect(() => {
if (status === AUTH_STATUSES.SUCCESS && otpId) {
router.push({
pathname: "/auth/verifyOtp",
params: { otpId, phoneNumber },
});
}
}, [status, otpId, router]);
return (
<KeyboardAvoidingView style={styles.container} behavior="padding">
<StatusBar barStyle="dark-content" backgroundColor="#F3F5F8" />
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={styles.inner}>
<Text style={styles.title}>Welcome to Driver Saathi</Text>
<Formik
initialValues={{ phone: "" }}
onSubmit={(values) => dispatch(sendOTP({ phone: values.phone }))}
validationSchema={phoneValidationSchema}
>
{({
handleChange,
handleBlur,
handleSubmit,
values,
errors,
touched,
}) => (
<View style={styles.form}>
<Text style={styles.label}>Phone Number</Text>
<TextInput
style={{
...styles.input,
color: values.phone ? "black" : "#949CAC",
}}
onChangeText={(text) => {
handleChange("phone")(text);
if (sendOTPError) {
dispatch(clearSendOTPError());
}
}}
onBlur={handleBlur("phone")}
value={values.phone}
keyboardType="numeric"
placeholder="Enter your registered phone number"
/>
<View style={styles.errorContainer}>
{touched.phone && errors.phone && (
<Text style={styles.error}>{errors.phone}</Text>
)}
{sendOTPError && (
<Text style={styles.error}>{sendOTPError}</Text>
)}
</View>
<TouchableOpacity
onPress={handleSubmit as unknown as () => void}
style={{
...styles.button,
backgroundColor:
values.phone.length === 10 &&
!errors.phone &&
status !== AUTH_STATUSES.LOADING
? "#008761"
: "#B0B7C5",
}}
disabled={
values.phone.length !== 10 ||
!!errors.phone ||
status === AUTH_STATUSES.LOADING
}
>
<Text style={styles.buttonText}>Send OTP</Text>
</TouchableOpacity>
</View>
)}
</Formik>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
errorContainer: {
flexDirection: "column",
gap: 8,
marginTop: 8,
},
error: {
color: "#D51D10",
fontFamily: "Inter",
fontSize: 12,
fontWeight: "bold",
lineHeight: 20,
},
inner: {
flex: 1,
justifyContent: "flex-start",
position: "relative",
},
form: {
height: "90%",
position: "relative",
},
button: {
height: 48,
borderRadius: 4,
alignItems: "center",
justifyContent: "center",
marginTop: 20,
width: "100%",
position: "absolute",
bottom: 50,
},
buttonText: {
color: "#FCFCFC",
fontFamily: "Inter",
fontSize: 14,
fontWeight: "bold",
lineHeight: 20,
},
container: {
flex: 1,
padding: 16,
backgroundColor: "#F3F5F8",
paddingTop: 0,
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 16,
color: "#1A1C1E",
fontStyle: "normal",
lineHeight: 36,
letterSpacing: -0.56,
width: "70%",
},
label: {
fontSize: 14,
marginBottom: 8,
color: "#717B8C",
fontFamily: "Inter",
fontStyle: "normal",
fontWeight: "bold",
lineHeight: 20,
},
input: {
borderRadius: 4,
borderTopColor: "#D8DDE7",
borderBottomColor: "#D8DDE7",
borderLeftColor: "#D8DDE7",
borderRightColor: "#D8DDE7",
height: 40,
borderColor: "gray",
borderWidth: 1,
paddingHorizontal: 8,
fontSize: 14,
overflow: "hidden",
fontFamily: "Inter",
fontStyle: "normal",
lineHeight: 20,
fontWeight: "bold",
backgroundColor: "#ffffff",
},
});

View File

199
app/auth/verifyOtp.tsx Normal file
View File

@ -0,0 +1,199 @@
import React, { useEffect, useState } from "react";
import { Text, View, StyleSheet, TouchableOpacity } from "react-native";
import { useSelector, useDispatch } from "react-redux";
import { AppDispatch, RootState } from "../../store";
import { clearVerifyOTPError, sendOTP, verifyOTP } from "../../store/authSlice";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { OtpInput } from "react-native-otp-entry";
import { useRouter } from "expo-router";
import { AUTH_STATUSES } from "@/constants/config";
export default function VerifyOTP() {
const { phone, otpId, verifyOTPError, sendOTPError, status } = useSelector(
(state: RootState) => state.auth
);
const router = useRouter();
const dispatch = useDispatch<AppDispatch>();
const [seconds, setSeconds] = useState(30);
const [isTimerActive, setIsTimerActive] = useState(true);
const [resendAttempts, setResendAttempts] = useState(0);
const maxResendAttempts = 5;
useEffect(() => {
dispatch(clearVerifyOTPError());
}, []);
useEffect(() => {
if (isTimerActive && seconds > 0) {
const timer = setInterval(() => {
setSeconds((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
} else if (seconds === 0) {
setIsTimerActive(false);
}
}, [seconds, isTimerActive]);
const resendOTP = async () => {
if (resendAttempts < maxResendAttempts) {
const newAttempts = resendAttempts + 1;
setResendAttempts(newAttempts);
setSeconds(30);
setIsTimerActive(true);
dispatch(sendOTP({ phone }));
dispatch(clearVerifyOTPError());
}
};
const formattedSeconds = seconds.toString().padStart(2, "0");
return (
<View style={styles.container}>
<Text style={styles.heading}>Verify Code</Text>
<View style={styles.body}>
<Text>
<Text style={styles.order}>
Please enter verification code that we've sent to your number
</Text>
<Text style={styles.phone}> +91 ********{phone?.slice(-2)}.</Text>
</Text>
<View style={styles.otpContainer}>
<OtpInput
numberOfDigits={4}
focusColor="#006C4D"
onTextChange={() => {
dispatch(clearVerifyOTPError());
}}
onFilled={(code) => {
dispatch(verifyOTP({ phone, otpId, otp: code }));
}}
autoFocus={true}
type="numeric"
focusStickBlinkingDuration={500}
textInputProps={{
accessibilityLabel: "One-Time Password",
}}
textProps={{
accessibilityRole: "text",
accessibilityLabel: "OTP digit",
allowFontScaling: false,
}}
/>
{(verifyOTPError || sendOTPError) && (
<Text style={styles.error}>{verifyOTPError || sendOTPError}</Text>
)}
<View style={styles.otpResend}>
{isTimerActive ? (
<Text>
<Text style={styles.otpText}>Resend OTP in </Text>
<Text style={styles.timer}>00:{formattedSeconds}</Text>
</Text>
) : (
<TouchableOpacity
onPress={resendOTP}
disabled={resendAttempts >= maxResendAttempts}
>
<Text
style={{
...styles.resendOTP,
color:
resendAttempts < maxResendAttempts
? "#006C4D"
: "#B0B7C5",
}}
>
Resend OTP
</Text>
</TouchableOpacity>
)}
</View>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
paddingTop: 0,
},
heading: {
fontSize: 28,
fontStyle: "normal",
fontWeight: "bold",
lineHeight: 36.4,
letterSpacing: -0.56,
},
body: {
marginTop: 24,
},
phone: {
color: "#252A34",
fontSize: 14,
fontWeight: "bold",
lineHeight: 20,
},
order: {
color: "#949CAC",
fontSize: 14,
fontWeight: "bold",
lineHeight: 20,
},
otpContainer: {
padding: 0,
marginTop: 32,
alignItems: "center",
justifyContent: "center",
},
otpInput: {
width: "80%",
justifyContent: "space-between",
},
inputField: {
borderColor: "#D8DDE7",
borderRadius: 4,
backgroundColor: "#ffffff",
color: "#252A34",
textAlign: "center",
fontFamily: "Inter",
fontSize: 20,
fontWeight: "bold",
height: 56,
width: 56,
},
error: {
color: "#D51D10",
fontFamily: "Inter",
fontSize: 12,
fontWeight: "bold",
lineHeight: 20,
marginTop: 8,
},
otpResend: {
marginTop: 28,
},
otpText: {
color: "#949CAC",
fontFamily: "Inter",
fontSize: 14,
fontWeight: "bold",
},
timer: {
color: "#252A34",
fontSize: 14,
fontWeight: "bold",
},
resendOTP: {
fontFamily: "Inter",
fontSize: 14,
fontWeight: "bold",
paddingTop: 8,
paddingBottom: 8,
},
});

219
store/authSlice.ts Normal file
View File

@ -0,0 +1,219 @@
// type PayloadAction<T> = {
// type: string;
// payload: T;
// }
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import axios from "axios";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import urls, {
STORAGE_KEYS,
MESSAGES,
StatusType,
AUTH_STATUSES,
} from "../constants/config";
interface AuthState {
phone: string | null;
otpId: number | null;
isLoggedIn: boolean;
status: StatusType;
sendOTPError: string | null;
verifyOTPError: string | null;
generalError: string | null;
}
interface SendOTPResponse {
status: boolean;
message: string;
data: {
otpId: number;
};
}
interface VerifyOTPResponse {
status: boolean;
message: string;
token: string;
refreshToken: string;
}
interface SendOTPParams {
phone: string | null;
}
interface VerifyOTPParams {
phone: string | null;
otp: string | null;
otpId: number | null;
}
const initialState: AuthState = {
phone: null,
otpId: null,
isLoggedIn: false,
status: AUTH_STATUSES.IDLE,
sendOTPError: null,
verifyOTPError: null,
generalError: null,
};
//async thunk to send otp
export const sendOTP = createAsyncThunk<SendOTPResponse, SendOTPParams>(
"auth/sendOTP",
async (params, { rejectWithValue }) => {
try {
console.log(urls.BASE_URL, "BASE_URL");
// const response = await axios.post<SendOTPResponse>(
// `${urls.BASE_URL}/send-otp`,
// params
// );
// return response.data;
return {
status: true,
message: "Done",
data: {
otpId: 22,
},
};
// if (!response.data.status) throw new Error(response.data.message);
} catch (error: any) {
return rejectWithValue(error.response?.data?.message || error.message);
}
}
);
export const verifyOTP = createAsyncThunk<VerifyOTPResponse, VerifyOTPParams>(
"auth/verifyOTP",
async (params, { rejectWithValue }) => {
try {
const response = await axios.post<VerifyOTPResponse>(
`${urls.BASE_URL}/verify-otp`,
params
);
if (!response.data.status) throw new Error(response.data.message);
// Store tokens
await AsyncStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, response.data.token);
await SecureStore.setItemAsync(
STORAGE_KEYS.REFRESH_TOKEN,
response.data.refreshToken
);
return response.data;
} catch (error: any) {
return rejectWithValue(MESSAGES.AUTHENTICATION.VERIFICATION_FAILED);
}
}
);
export const logout = createAsyncThunk(
"auth/logout",
async (_, { dispatch }) => {
await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
await SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
dispatch(authSlice.actions.logout());
}
);
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
loginRequest: (state) => {
state.status = AUTH_STATUSES.LOADING;
},
loginSuccess: (state, action) => {
state.status = AUTH_STATUSES.SUCCESS;
state.isLoggedIn = true; // Set logged-in state to true
},
setIsLoggedIn: (state, action) => {
state.isLoggedIn = action.payload;
},
loginFailure: (state, action: PayloadAction<string>) => {
state.status = AUTH_STATUSES.FAILED;
state.generalError = action.payload;
},
logout: (state) => {
state.isLoggedIn = false;
},
clearSendOTPError: (state) => {
state.sendOTPError = null;
},
clearVerifyOTPError: (state) => {
state.verifyOTPError = null;
},
clearAllErrors: (state) => {
state.sendOTPError = null;
state.verifyOTPError = null;
state.generalError = null;
},
},
extraReducers: (builder) => {
builder
.addCase(sendOTP.pending, (state) => {
state.status = AUTH_STATUSES.LOADING;
state.sendOTPError = null;
})
.addCase(sendOTP.fulfilled, (state, action) => {
state.status = AUTH_STATUSES.SUCCESS;
state.otpId = action.payload.data.otpId;
state.phone = action.meta.arg.phone;
state.sendOTPError = null;
})
.addCase(sendOTP.rejected, (state, action) => {
state.status = AUTH_STATUSES.FAILED;
state.sendOTPError = action.error.message || "Failed to send OTP";
})
.addCase(verifyOTP.pending, (state) => {
state.status = AUTH_STATUSES.LOADING;
state.verifyOTPError = null;
})
.addCase(verifyOTP.fulfilled, (state, action) => {
state.status = AUTH_STATUSES.SUCCESS;
state.isLoggedIn = true;
state.verifyOTPError = null;
const token = action.payload.token;
AsyncStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, token).catch((error) => {
console.log("Error storing token", error);
});
const refreshToken = action.payload.refreshToken;
SecureStore.setItemAsync(
STORAGE_KEYS.REFRESH_TOKEN,
refreshToken
).catch((error) => {
console.log("Error storing refresh token", error);
});
})
.addCase(verifyOTP.rejected, (state, action) => {
state.status = AUTH_STATUSES.FAILED;
state.verifyOTPError = action.error.message || "Failed to verify OTP";
})
.addCase(logout.pending, (state) => {
state.status = AUTH_STATUSES.LOADING;
})
.addCase(logout.fulfilled, (state) => {
state.otpId = null;
state.isLoggedIn = false;
state.status = AUTH_STATUSES.IDLE;
state.verifyOTPError = null;
state.generalError = null;
state.sendOTPError = null;
state.phone = null;
})
.addCase(logout.rejected, (state, action) => {
state.status = AUTH_STATUSES.FAILED;
state.generalError = action.error.message || "Failed to log out";
});
},
});
export const {
logout: localLogout,
clearSendOTPError,
clearVerifyOTPError,
clearAllErrors,
} = authSlice.actions;
export default authSlice.reducer;

13
store/index.ts Normal file
View File

@ -0,0 +1,13 @@
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./rootReducer";
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

9
store/rootReducer.ts Normal file
View File

@ -0,0 +1,9 @@
import { combineReducers } from "@reduxjs/toolkit";
import authreducer from "./authSlice";
const rootReducer = combineReducers({
auth: authreducer,
});
export default rootReducer;
export type RootState = ReturnType<typeof rootReducer>;