From 189f8bd67344b686d0db0e51c8c371addf5e6440 Mon Sep 17 00:00:00 2001 From: vinay kumar Date: Tue, 1 Jul 2025 12:00:35 +0530 Subject: [PATCH] Add login functionality --- app/(tabs)/_layout.tsx | 20 ++-- app/(tabs)/index.tsx | 132 +++++++++++++++++++++---- app/_layout.tsx | 19 ++-- app/auth/_layout.tsx | 24 +++++ app/auth/login.tsx | 213 +++++++++++++++++++++++++++++++++++++++ app/auth/verify.tsx | 0 app/auth/verifyOtp.tsx | 199 +++++++++++++++++++++++++++++++++++++ store/authSlice.ts | 219 +++++++++++++++++++++++++++++++++++++++++ store/index.ts | 13 +++ store/rootReducer.ts | 9 ++ 10 files changed, 810 insertions(+), 38 deletions(-) create mode 100644 app/auth/_layout.tsx create mode 100644 app/auth/login.tsx delete mode 100644 app/auth/verify.tsx create mode 100644 app/auth/verifyOtp.tsx create mode 100644 store/authSlice.ts create mode 100644 store/index.ts create mode 100644 store/rootReducer.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index ef8fd11..cafb2ad 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( {TAB_CONFIG.map(({ name, title, Icon, IconFilled }) => ( diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 92540e6..4243324 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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: () => ( + + Yatri - NBX 600 + DL253C3602 + + ), + headerRight: () => ( + + setIsSupportModalVisible(true)} + > + + + + + ), + }); + }, [navigation]); + return ( - - {t("welcome")} - {t("change-language")} - + + + + + + + + + + + {}} + /> + {/* */} + + + setIsSupportModalVisible(false)} /> - - + ); } 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", }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index dc53bdd..04c2af8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 ( - - - - - - + + + + + + + + ); } diff --git a/app/auth/_layout.tsx b/app/auth/_layout.tsx new file mode 100644 index 0000000..037153c --- /dev/null +++ b/app/auth/_layout.tsx @@ -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 ( + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#F3F5F8", + }, +}); diff --git a/app/auth/login.tsx b/app/auth/login.tsx new file mode 100644 index 0000000..3298b6a --- /dev/null +++ b/app/auth/login.tsx @@ -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(); + 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 ( + + + + + Welcome to Driver Saathi + + dispatch(sendOTP({ phone: values.phone }))} + validationSchema={phoneValidationSchema} + > + {({ + handleChange, + handleBlur, + handleSubmit, + values, + errors, + touched, + }) => ( + + Phone Number + { + handleChange("phone")(text); + if (sendOTPError) { + dispatch(clearSendOTPError()); + } + }} + onBlur={handleBlur("phone")} + value={values.phone} + keyboardType="numeric" + placeholder="Enter your registered phone number" + /> + + {touched.phone && errors.phone && ( + {errors.phone} + )} + {sendOTPError && ( + {sendOTPError} + )} + + 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 + } + > + Send OTP + + + )} + + + + + ); +} + +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", + }, +}); diff --git a/app/auth/verify.tsx b/app/auth/verify.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/app/auth/verifyOtp.tsx b/app/auth/verifyOtp.tsx new file mode 100644 index 0000000..375c942 --- /dev/null +++ b/app/auth/verifyOtp.tsx @@ -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(); + + 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 ( + + Verify Code + + + + Please enter verification code that we've sent to your number + + +91 ********{phone?.slice(-2)}. + + + + { + 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) && ( + {verifyOTPError || sendOTPError} + )} + + + {isTimerActive ? ( + + Resend OTP in + 00:{formattedSeconds} + + ) : ( + = maxResendAttempts} + > + + Resend OTP + + + )} + + + + + ); +} + +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, + }, +}); diff --git a/store/authSlice.ts b/store/authSlice.ts new file mode 100644 index 0000000..14b5a0b --- /dev/null +++ b/store/authSlice.ts @@ -0,0 +1,219 @@ +// type PayloadAction = { +// 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( + "auth/sendOTP", + async (params, { rejectWithValue }) => { + try { + console.log(urls.BASE_URL, "BASE_URL"); + // const response = await axios.post( + // `${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( + "auth/verifyOTP", + async (params, { rejectWithValue }) => { + try { + const response = await axios.post( + `${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) => { + 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; diff --git a/store/index.ts b/store/index.ts new file mode 100644 index 0000000..d9773ca --- /dev/null +++ b/store/index.ts @@ -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; +export type AppDispatch = typeof store.dispatch; diff --git a/store/rootReducer.ts b/store/rootReducer.ts new file mode 100644 index 0000000..2bb4180 --- /dev/null +++ b/store/rootReducer.ts @@ -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;