Work on Payments screen

feature/app-setup
vinay kumar 2025-08-08 19:02:15 +05:30
parent 7c99b629fc
commit 9d5869c3c2
16 changed files with 1126 additions and 19 deletions

View File

@ -18,7 +18,7 @@
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<meta-data <meta-data
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyBfz20PwTeQkeMonsgXETOViBAy9LOBlXY"/> android:value="AIzaSyCJ4IkKM0ybTOwvdylLVx9BxL2ZV1PEvRk"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>

View File

@ -8,6 +8,7 @@ import { useSnackbar } from "@/contexts/Snackbar";
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { getUserDetails } from "@/store/userSlice"; import { getUserDetails } from "@/store/userSlice";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { logout } from "@/store/authSlice";
export default function TabLayout() { export default function TabLayout() {
const { isLoggedIn } = useSelector((state: RootState) => state.auth); const { isLoggedIn } = useSelector((state: RootState) => state.auth);
@ -38,6 +39,7 @@ export default function TabLayout() {
initSocket(); initSocket();
} catch (error) { } catch (error) {
console.error("Failed to fetch user details", error); console.error("Failed to fetch user details", error);
logout();
showSnackbar("Failed to fetch user details", "error"); showSnackbar("Failed to fetch user details", "error");
} }
}; };

View File

@ -25,6 +25,9 @@ import { useRouter } from "expo-router";
import ProfileImage from "@/components/home/Profile"; import ProfileImage from "@/components/home/Profile";
import { calculateBearing, calculateDistance } from "@/utils/Map"; import { calculateBearing, calculateDistance } from "@/utils/Map";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import RefreshIcon from "@/assets/icons/refresh.svg";
import { disconnectSocket, initSocket } from "@/services/socket";
import { setTelemetryError, setTelemetryLoading } from "@/store/telemetrySlice";
export default function HomeScreen() { export default function HomeScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -53,7 +56,6 @@ export default function HomeScreen() {
const warrantyStartDate = data?.batteries[0]?.warranty_start_date || null; const warrantyStartDate = data?.batteries[0]?.warranty_start_date || null;
const warrantyEndDate = data?.batteries[0]?.warranty_end_date || null; const warrantyEndDate = data?.batteries[0]?.warranty_end_date || null;
const step = 0.001;
useEffect(() => { useEffect(() => {
if (lat && lon) { if (lat && lon) {
if (prevPosition) { if (prevPosition) {
@ -108,6 +110,9 @@ export default function HomeScreen() {
), ),
headerRight: () => ( headerRight: () => (
<View style={styles.rightContainer}> <View style={styles.rightContainer}>
<Pressable onPress={() => handleManualRefresh()}>
<RefreshIcon width={50} height={50} />
</Pressable>
<Pressable <Pressable
style={styles.supportButton} style={styles.supportButton}
onPress={() => { onPress={() => {
@ -115,7 +120,7 @@ export default function HomeScreen() {
setIsSupportModalVisible(true); setIsSupportModalVisible(true);
}} }}
> >
<CustomerCareIcon /> <CustomerCareIcon width={50} height={50} />
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => { onPress={() => {
@ -141,6 +146,18 @@ export default function HomeScreen() {
); );
}; };
const handleManualRefresh = async () => {
dispatch(setTelemetryLoading());
try {
disconnectSocket();
await initSocket();
} catch (err) {
console.error("Refresh failed", err);
dispatch(setTelemetryError("Refresh failed"));
}
};
return ( return (
<View style={{ flex: 1, backgroundColor: "#F3F5F8" }}> <View style={{ flex: 1, backgroundColor: "#F3F5F8" }}>
<StatusBar style="dark" /> <StatusBar style="dark" />
@ -187,6 +204,7 @@ export default function HomeScreen() {
longitudeDelta: 0.0421, longitudeDelta: 0.0421,
}} }}
// customMapStyle={mapStyle} // customMapStyle={mapStyle}
userInterfaceStyle="light"
> >
<Marker <Marker
draggable draggable
@ -384,7 +402,7 @@ const styles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingRight: 16, paddingRight: 16,
gap: 8, gap: 4,
backgroundColor: "#F3F5F8", backgroundColor: "#F3F5F8",
}, },
badge: { badge: {

View File

@ -1,27 +1,778 @@
import { StyleSheet } from "react-native"; import { useLayoutEffect, useMemo, useState, useEffect } from "react";
import { useNavigation, useRouter } from "expo-router";
import {
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
} from "react-native";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useSelector } from "react-redux";
import { RootState } from "@/store";
import ProfileImage from "@/components/home/Profile";
import { Overlay } from "@/components/common/Overlay";
import { useSnackbar } from "@/contexts/Snackbar";
import CustomerCareIcon from "../../assets/icons/customer-care.svg";
import api from "@/services/axiosClient";
import PaymentHistoryCard from "@/components/Payments/PaymentHistoryCard";
import { BASE_URL } from "@/constants/config";
// Type definitions
interface MyPlan {
no_of_emi: number;
total_amount: number;
down_payment: number;
emi_amount: number;
total_emi: number;
installment_paid: number;
current_amount: number;
}
interface EmiDetails {
due_amount: number;
total_amount_paid_in_current_cycle: number;
due_date: string;
status: string;
advance_balance: number;
pending_cycles: number;
total_pending_installments: number;
myPlain: MyPlan;
}
interface EmiResponse {
success: boolean;
data: EmiDetails[];
}
interface PaymentHistoryItem {
id: number;
amount: string;
status: string;
created_at: string;
payment_mode: string[];
payment_date: string;
}
interface PaymentHistoryPagination {
total_records: number;
page_number: number;
page_size: number;
}
interface PaymentHistoryResponse {
success: boolean;
data: {
payments: PaymentHistoryItem[];
pagination: PaymentHistoryPagination;
};
}
const formatPaymentDate = (dateString: string) => {
try {
// Parse the formatted date from API (e.g., "7 Aug 2025, 5:23:58 pm")
return dateString.split(",")[0]; // Extract just the date part
} catch {
return dateString;
}
};
// Format time for payment history
const formatPaymentTime = (dateString: string) => {
try {
// Extract time and day info from the formatted date
const parts = dateString.split(",");
if (parts.length > 1) {
const timePart = parts[1].trim(); // "5:23:58 pm"
const date = new Date(dateString);
const dayName = date.toLocaleDateString("en-US", { weekday: "long" });
return `${timePart} | ${dayName}`;
}
return dateString;
} catch {
return dateString;
}
};
export default function PaymentsTabScreen() { export default function PaymentsTabScreen() {
const navigation = useNavigation();
const { data } = useSelector((state: RootState) => state.user);
const router = useRouter();
const { showSnackbar } = useSnackbar();
const [emiDetails, setEmiDetails] = useState<EmiDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSupportModalVisible, setIsSupportModalVisible] = useState(false);
const [isEndReached, setIsEndReached] = useState(false);
//payment history states
const [paymentHistory, setPaymentHistory] = useState<PaymentHistoryItem[]>(
[]
);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [showFullHistory, setShowFullHistory] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [hasMorePages, setHasMorePages] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const vehicle =
Array.isArray(data?.vehicles) && data.vehicles.length > 0
? data.vehicles[0]
: null;
const battery =
Array.isArray(data?.batteries) && data.batteries.length > 0
? data.batteries[0]
: null;
const model = vehicle?.model ?? "---";
const chasisNumber = vehicle?.chasis_number ?? "---";
// Fetch EMI details
useEffect(() => {
const fetchEmiDetails = async () => {
try {
setIsLoading(true);
const response = await api.get(`/api/v1/emi-details`);
const result: EmiResponse = response.data;
if (result.success && result.data.length > 0) {
setEmiDetails(result.data[0]);
} else {
showSnackbar("No EMI details found", "error");
}
} catch (err) {
console.error("Error fetching EMI details:", err);
const errorMessage =
err instanceof Error ? err.message : "Something went wrong";
showSnackbar(errorMessage, "error");
} finally {
setIsLoading(false);
}
};
fetchEmiDetails();
}, []);
useLayoutEffect(() => {
navigation.setOptions({
headerStyle: {
backgroundColor: "#F3F5F8",
},
headerTitle: () => (
<View style={styles.headerTitleContainer}>
<Text style={styles.title}>{model}</Text>
<Text style={styles.subtitle}>{chasisNumber}</Text>
</View>
),
headerRight: () => (
<View style={styles.rightContainer}>
<Pressable
style={styles.supportButton}
onPress={() => {
console.log("Support Pressed");
setIsSupportModalVisible(true);
}}
>
<CustomerCareIcon />
</Pressable>
<Pressable
onPress={() => {
router.push("/user/profile");
}}
>
<ProfileImage
username={data?.name || "User"}
onClick={() => router.push("/user/profile")}
textSize={20}
boxSize={40}
/>
</Pressable>
</View>
),
});
}, [navigation, data, model, chasisNumber]);
// Format currency
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString()}`;
};
const handleViewAll = () => {
setShowFullHistory(true);
};
// Format date
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-IN", {
day: "2-digit",
month: "short",
year: "numeric",
});
} catch {
return dateString;
}
};
// Get current month/year for header
const getCurrentMonthYear = () => {
const date = new Date();
return date.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
};
const fetchPaymentHistory = async (
pageNumber: number = 1,
isLoadMore: boolean = false
) => {
try {
if (isLoadMore) {
setIsLoadingMore(true);
} else {
setIsHistoryLoading(true);
}
const response = await api.get(
`${BASE_URL}/api/v1/payment-history?page_number=${pageNumber}&page_size=10`
);
const result: PaymentHistoryResponse = response.data;
console.log("Payment History Response:", result);
if (result.success) {
const newPayments = result.data.payments;
if (isLoadMore) {
setPaymentHistory((prev) => [...prev, ...newPayments]);
} else {
setPaymentHistory(newPayments);
}
// Check if there are more pages
const totalPages = Math.ceil(
result.data.pagination.total_records /
result.data.pagination.page_size
);
setHasMorePages(pageNumber < totalPages);
setCurrentPage(pageNumber);
} else {
showSnackbar("No payment history found", "error");
}
} catch (err) {
console.error("Error fetching payment history:", err);
const errorMessage =
err instanceof Error ? err.message : "Something went wrong";
showSnackbar(errorMessage, "error");
} finally {
if (isLoadMore) {
setIsLoadingMore(false);
} else {
setIsHistoryLoading(false);
}
}
};
const handleLoadMore = () => {
if (hasMorePages && !isLoadingMore) {
fetchPaymentHistory(currentPage + 1, true);
}
};
useEffect(() => {
if (isEndReached && showFullHistory && hasMorePages && !isLoadingMore) {
handleLoadMore();
}
}, [isEndReached]);
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20;
const isAtBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom;
setIsEndReached(isAtBottom);
};
useEffect(() => {
fetchPaymentHistory(1, false);
}, []);
return ( return (
<View style={styles.container}> <ScrollView
<Text style={styles.title}>Coming Soon...</Text> style={styles.scrollContainer}
</View> showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
>
{/* Last EMI Details Card */}
<View style={styles.emiCard}>
{/* Header */}
<View style={styles.cardHeader}>
<Text style={styles.headerTitle}>Last EMI Details</Text>
<Text style={styles.headerDate}>{getCurrentMonthYear()}</Text>
</View>
{/* Divider */}
<View style={styles.divider} />
{/* EMI Details Content */}
{true && (
<View style={styles.cardContent}>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Amount Due</Text>
<Text style={styles.detailValue}>
{emiDetails?.due_amount
? formatCurrency(emiDetails.due_amount)
: "---"}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Amount Paid</Text>
<Text style={styles.detailValue}>
{emiDetails?.total_amount_paid_in_current_cycle &&
formatCurrency(emiDetails.total_amount_paid_in_current_cycle)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Due Date</Text>
<Text style={styles.detailValue}>
{emiDetails?.due_date && emiDetails.due_date}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Payment Status</Text>
<StatusBadge
label={emiDetails?.status && emiDetails.status}
type={emiDetails?.status && emiDetails.status.toLowerCase()}
/>
</View>
</View>
)}
{/* Divider */}
<View style={styles.divider} />
{/* Action Buttons */}
<View style={styles.buttonContainer}>
<TouchableOpacity style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Pay EMI</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.tertiaryButton}>
<Text style={styles.tertiaryButtonText}>View Plan</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.paymentHistorySection}>
<Text style={styles.sectionTitle}>Payment History</Text>
<View style={styles.paymentHistoryContainer}>
{isHistoryLoading ? (
<View style={styles.historyLoadingContainer}>
<Text style={styles.loadingText}>Loading payment history...</Text>
</View>
) : (
<>
{/* Show initial payments or all payments based on showFullHistory */}
{(showFullHistory
? paymentHistory
: paymentHistory.slice(0, 2)
).map((payment) => (
<PaymentHistoryCard
key={payment.id}
date={formatPaymentDate(payment.payment_date)}
amount={formatCurrency(parseFloat(payment.amount))}
time={formatPaymentTime(payment.payment_date)}
method={payment.payment_mode.join(", ")}
status={payment.status}
/>
))}
{/* View All button */}
{!showFullHistory && paymentHistory.length > 2 && (
<TouchableOpacity
style={styles.viewAllButton}
onPress={handleViewAll}
>
<Text style={styles.viewAllText}>View all</Text>
<Text style={styles.chevron}></Text>
</TouchableOpacity>
)}
{/* Loading indicator when fetching more */}
{isLoadingMore && (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>
Loading more payments...
</Text>
</View>
)}
{/* No more data message */}
{showFullHistory &&
!hasMorePages &&
paymentHistory.length > 0 && (
<View style={styles.noMoreDataContainer}>
<Text style={styles.noMoreDataText}>
No more payments to show
</Text>
</View>
)}
{/* Empty state */}
{paymentHistory.length === 0 && !isHistoryLoading && (
<View style={styles.noDataContainer}>
<Text style={styles.noDataText}>
No payment history found
</Text>
</View>
)}
</>
)}
</View>
</View>
<Overlay isUploading={isLoading} />
</ScrollView>
); );
} }
const StatusBadge = ({ label, type }: { label: string; type: string }) => {
if (!label) return null;
const getBadgeStyle = (type: string) => {
switch (type) {
case "pending":
return {
backgroundColor: "#FFF0E3",
color: "#8E4400",
};
case "failed":
return {
backgroundColor: "#FDE8E7",
color: "#D51C10",
};
case "completed":
case "paid":
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",
};
default:
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",
};
}
};
const badgeStyle = getBadgeStyle(type);
return (
<View
style={[styles.badge, { backgroundColor: badgeStyle.backgroundColor }]}
>
<Text style={[styles.badgeText, { color: badgeStyle.color }]}>
{label.charAt(0).toUpperCase() + label.slice(1)}
</Text>
</View>
);
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
loadingContainer: {
padding: 16,
alignItems: "center",
justifyContent: "center",
},
container: { container: {
flex: 1, flex: 1,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
title: { title: {
fontSize: 20, fontSize: 14,
fontWeight: "bold", color: "#6B7280",
fontWeight: "500",
}, },
separator: { separator: {
marginVertical: 30, marginVertical: 30,
height: 1, height: 1,
width: "80%", width: "80%",
}, },
headerTitleContainer: {
flexDirection: "column",
backgroundColor: "#F3F5F8",
},
subtitle: {
fontSize: 18,
color: "#111827",
fontWeight: "700",
},
rightContainer: {
flexDirection: "row",
alignItems: "center",
paddingRight: 16,
gap: 8,
backgroundColor: "#F3F5F8",
},
supportButton: {
backgroundColor: "#F3F5F8",
},
scrollContainer: {
flex: 1,
paddingHorizontal: 16,
paddingBottom: 16,
width: "100%",
},
// EMI Card Styles
emiCard: {
backgroundColor: "#FCFCFC",
borderRadius: 8,
padding: 12,
marginBottom: 36,
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
marginBottom: 12,
},
headerTitle: {
fontSize: 14,
fontWeight: "600",
color: "#252A34",
},
headerDate: {
fontSize: 14,
fontWeight: "500",
color: "#565C70",
},
divider: {
height: 1,
backgroundColor: "#E5E9F0",
marginBottom: 12,
},
cardContent: {
gap: 8,
marginBottom: 12,
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
minHeight: 20,
},
detailLabel: {
fontSize: 14,
color: "#252A34",
flex: 1,
},
detailValue: {
fontSize: 14,
fontWeight: "600",
color: "#252A34",
},
// Badge Styles
badge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
badgeText: {
fontSize: 12,
fontWeight: "500",
},
// Button Styles
buttonContainer: {
gap: 8,
},
primaryButton: {
backgroundColor: "#008761",
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 16,
alignItems: "center",
minHeight: 40,
justifyContent: "center",
},
primaryButtonText: {
color: "#FCFCFC",
fontSize: 14,
fontWeight: "500",
},
tertiaryButton: {
borderRadius: 4,
paddingVertical: 8,
alignItems: "center",
minHeight: 36,
justifyContent: "center",
},
tertiaryButtonText: {
color: "#006C4D",
fontSize: 14,
fontWeight: "500",
},
// Plan Details Styles
planDetailsSection: {
gap: 8,
marginBottom: 20,
},
planCard: {
backgroundColor: "#FCFCFC",
borderRadius: 8,
padding: 12,
},
paymentHistorySection: {
width: 328,
marginLeft: 16,
marginTop: 20,
// No height fixed here, flexible container
},
sectionTitle: {
fontFamily: "Inter-SemiBold",
fontSize: 14,
lineHeight: 20,
color: "#252A33", // rgb(37,42,51) from design
marginBottom: 8,
},
paymentHistoryContainer: {
width: 328,
flexDirection: "column",
gap: 16, // for spacing between cards, might need polyfill or use margin
},
historyLoadingContainer: {
height: 312,
justifyContent: "center",
alignItems: "center",
},
loadingText: {
fontFamily: "Inter-Regular",
fontSize: 14,
color: "#252A33",
},
noDataContainer: {
height: 312,
justifyContent: "center",
alignItems: "center",
},
noDataText: {
fontFamily: "Inter-Regular",
fontSize: 14,
color: "#252A33",
},
noMoreDataContainer: {
marginTop: 16,
justifyContent: "center",
alignItems: "center",
},
noMoreDataText: {
fontFamily: "Inter-Regular",
fontSize: 12,
color: "#5B6478", // rgb(91,100,120) muted text
},
paymentCard: {
width: 328,
height: 76,
backgroundColor: "#FCFCFC", // #FCFCFC or #FBFBFB as in design (near white)
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 12,
marginBottom: 16,
justifyContent: "space-between",
// flexDirection: "column" by default
},
paymentCardTopRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
height: 20,
},
paymentDate: {
fontFamily: "Inter-Regular",
fontSize: 14,
color: "#252A33",
},
paymentAmount: {
fontFamily: "Inter-SemiBold",
fontSize: 14,
color: "#252A33",
},
paymentCardBottomRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
height: 20,
},
paymentTimeMethod: {
fontFamily: "Inter-Regular",
fontSize: 12,
color: "#56607A", // rgb(86,96,122)
},
paymentStatusBadge: {
borderRadius: 4,
paddingVertical: 2,
paddingHorizontal: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
paymentStatusLabel: {
fontFamily: "Inter-Medium",
fontSize: 12,
textAlign: "center",
},
// Status colors — you can override backgroundColor and color dynamically based on status
statusPending: {
backgroundColor: "#FFF0E5", // approx. (1, 0.941, 0.89)
color: "#803F0C", // approx. (0.5, 0.27, 0.047)
},
statusFailed: {
backgroundColor: "#FDDDD7", // approx. (0.99, 0.91, 0.9)
color: "#D6290A", // approx. (0.83, 0.11, 0.06)
},
statusSuccess: {
backgroundColor: "#E6F4EA", // light green example
color: "#0B8235",
},
viewAllButton: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 4,
},
viewAllText: {
fontFamily: "Inter-Medium",
fontSize: 14,
color: "#007958", // greenish text color
},
chevron: {
fontSize: 18,
color: "#007958",
marginLeft: 4,
},
loadMoreButton: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 4,
backgroundColor: "#007958",
alignSelf: "center",
},
loadMoreText: {
fontFamily: "Inter-Medium",
fontSize: 14,
color: "#FFFFFF",
},
}); });

View File

@ -11,7 +11,6 @@ import * as SplashScreen from "expo-splash-screen";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { STORAGE_KEYS } from "@/constants/config"; import { STORAGE_KEYS } from "@/constants/config";
import { setIsLoggedIn } from "@/store/authSlice"; import { setIsLoggedIn } from "@/store/authSlice";
import { getUserDetails } from "@/store/userSlice";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();

8
assets/icons/refresh.svg Normal file
View File

@ -0,0 +1,8 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_425_4042" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="10" y="10" width="20" height="20">
<rect x="10" y="10" width="20" height="20" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_425_4042)">
<path d="M19.9997 26.6666C18.1386 26.6666 16.5622 26.0208 15.2705 24.7291C13.9788 23.4375 13.333 21.8611 13.333 20C13.333 18.1389 13.9788 16.5625 15.2705 15.2708C16.5622 13.9791 18.1386 13.3333 19.9997 13.3333C20.958 13.3333 21.8747 13.5312 22.7497 13.9271C23.6247 14.3229 24.3747 14.8889 24.9997 15.625V14.1666C24.9997 13.9305 25.0795 13.7326 25.2393 13.5729C25.399 13.4132 25.5969 13.3333 25.833 13.3333C26.0691 13.3333 26.267 13.4132 26.4268 13.5729C26.5865 13.7326 26.6663 13.9305 26.6663 14.1666V18.3333C26.6663 18.5694 26.5865 18.7673 26.4268 18.9271C26.267 19.0868 26.0691 19.1666 25.833 19.1666H21.6663C21.4302 19.1666 21.2323 19.0868 21.0726 18.9271C20.9129 18.7673 20.833 18.5694 20.833 18.3333C20.833 18.0972 20.9129 17.8993 21.0726 17.7396C21.2323 17.5798 21.4302 17.5 21.6663 17.5H24.333C23.8886 16.7222 23.2809 16.1111 22.5101 15.6666C21.7393 15.2222 20.9025 15 19.9997 15C18.6108 15 17.4302 15.4861 16.458 16.4583C15.4858 17.4305 14.9997 18.6111 14.9997 20C14.9997 21.3889 15.4858 22.5694 16.458 23.5416C17.4302 24.5139 18.6108 25 19.9997 25C20.9441 25 21.8087 24.7604 22.5934 24.2812C23.3781 23.8021 23.9858 23.1597 24.4163 22.3541C24.5275 22.1597 24.6837 22.0243 24.8851 21.9479C25.0865 21.8715 25.2913 21.868 25.4997 21.9375C25.7219 22.0069 25.8816 22.1528 25.9788 22.375C26.0761 22.5972 26.0691 22.8055 25.958 23C25.3886 24.1111 24.5761 25 23.5205 25.6666C22.465 26.3333 21.2913 26.6666 19.9997 26.6666Z" fill="#565F70"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,152 @@
import { Text, View } from "react-native";
interface PaymentHistoryCardProps {
date: string;
amount: string;
time: string;
method: string;
status: string;
}
export default ({
date,
amount,
time,
method,
status,
}: PaymentHistoryCardProps) => {
const getStatusStyle = (status: string) => {
switch (status.toLowerCase()) {
case "failure":
case "failed":
return {
backgroundColor: "#FDE8E7",
color: "#D51C10",
};
case "pending":
return {
backgroundColor: "#FFF0E3",
color: "#8E4400",
};
case "confirmed":
case "completed":
case "success":
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",
};
default:
return {
backgroundColor: "#E8F5E8",
color: "#2D7D32",
};
}
};
const statusStyle = getStatusStyle(status);
return (
<View style={styles.paymentCard}>
<View style={styles.paymentCardTop}>
<Text style={styles.paymentDate}>{date}</Text>
<Text style={styles.paymentAmount}>{amount}</Text>
</View>
<View style={styles.paymentCardBottom}>
<Text style={styles.paymentDetails}>
{time} {method}
</Text>
{status && (
<View
style={[
styles.statusBadge,
{ backgroundColor: statusStyle.backgroundColor },
]}
>
<Text style={[styles.statusText, { color: statusStyle.color }]}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</Text>
</View>
)}
</View>
</View>
);
};
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
paymentCard: {
width: 328,
height: 76,
backgroundColor: "rgba(252,252,252,1)", // #FCFCFC
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 12,
justifyContent: "flex-start",
alignItems: "flex-start",
},
paymentCardTop: {
width: 304,
height: 20,
marginLeft: 12,
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
gap: 4, // gap isn't officially supported in RN, so use marginRight on children instead
},
paymentDate: {
width: 231,
height: 20,
fontFamily: "Inter-Regular",
fontSize: 14,
lineHeight: 20,
color: "rgba(37,42,52,1)", // #252A34
textAlign: "left",
},
paymentAmount: {
width: 69,
height: 20,
fontFamily: "Inter-SemiBold",
fontSize: 14,
lineHeight: 20,
color: "rgba(37,42,52,1)", // #252A34
textAlign: "left",
marginLeft: 4, // simulate gap
},
paymentCardBottom: {
width: 304,
height: 20,
marginLeft: 12,
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
gap: 4, // simulate gap with margin
},
paymentDetails: {
width: 237,
height: 16,
fontFamily: "Inter-Regular",
fontSize: 12,
lineHeight: 16,
color: "rgba(86,95,112,1)", // #565F70
textAlign: "left",
marginTop: 2, // slight vertical offset from Figma y=2
},
statusBadge: {
width: 63,
height: 20,
borderRadius: 4,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 2,
marginLeft: 4, // simulate gap
},
statusText: {
fontFamily: "Inter-Medium",
fontSize: 12,
lineHeight: 16,
color: "#8E4400", // default, overridden by inline style from getStatusStyle
textAlign: "center",
},
});

View File

@ -22,7 +22,7 @@ const MetricCard: React.FC<MetricCardProps> = ({ heading, value, unit }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
width: 156, width: "48%",
height: 68, height: 68,
backgroundColor: "#FCFCFC", backgroundColor: "#FCFCFC",
borderRadius: 8, borderRadius: 8,

View File

@ -24,6 +24,7 @@ api.interceptors.response.use(
const status = error.response?.status; const status = error.response?.status;
//if token is expired or not present, clear it from storage //if token is expired or not present, clear it from storage
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
console.log("Token expired or not present");
await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
router.replace("/auth/login"); router.replace("/auth/login");
} }

View File

@ -3,6 +3,7 @@
"welcome": "Welcome to Driver Saathi", "welcome": "Welcome to Driver Saathi",
"enter-mobile-number": "Enter Mobile Number", "enter-mobile-number": "Enter Mobile Number",
"enter-registered-mobile-number": "Enter your registered mobile number", "enter-registered-mobile-number": "Enter your registered mobile number",
"for-any-queries-contact-us": "For any queries, ontact us",
"number-not-registered": "Number not registered.", "number-not-registered": "Number not registered.",
"enter-otp": "Please enter OTP sent to your mobile number", "enter-otp": "Please enter OTP sent to your mobile number",
"verify-otp": "Verify OTP", "verify-otp": "Verify OTP",

View File

@ -6,7 +6,7 @@ import {
setTelemetryError, setTelemetryError,
} from "../store/telemetrySlice"; } from "../store/telemetrySlice";
import { BmsState } from "@/constants/types"; import { BmsState } from "@/constants/types";
import { BASE_URL, VECTOR_BASE_URL } from "@/constants/config"; import { BASE_URL, SOCKET_BASE_URL, VECTOR_BASE_URL } from "@/constants/config";
import api from "./axiosClient"; import api from "./axiosClient";
import axios from "axios"; import axios from "axios";
@ -37,15 +37,15 @@ const fetchToken = async (): Promise<string> => {
}; };
const fetchControllingServer = async (token: string): Promise<string> => { const fetchControllingServer = async (token: string): Promise<string> => {
const hardwareDeviceId = store.getState().user.data?.batteries[0]?.battery_id; const hardwareDeviceId = store.getState().user.data?.batteries[0]?.device_id;
console.log("Hardware Device ID:", store.getState().user.data);
// const hardwareDeviceId = "VEC16000866082076280974";
try { try {
if (!hardwareDeviceId) throw new Error("Missing hardwareDeviceId"); if (!hardwareDeviceId) throw new Error("Missing hardwareDeviceId");
const response = await axios.get<string>( const response = await axios.get<string>(
`${VECTOR_BASE_URL}/api/device-management/dashboard/get/hardwareDeviceId/${hardwareDeviceId.substring( `${VECTOR_BASE_URL}/api/device-management/dashboard/get/hardwareDeviceId/${hardwareDeviceId}`,
1
)}`,
{ {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@ -61,7 +61,8 @@ const fetchControllingServer = async (token: string): Promise<string> => {
}; };
export const connectSocket = () => { export const connectSocket = () => {
const hardwareDeviceId = store.getState().user.data?.batteries[0]?.battery_id; // const hardwareDeviceId = store.getState().user.data?.batteries[0]?.battery_id;
const hardwareDeviceId = "V16000866082076280974";
if (!token || !controllingServer || !hardwareDeviceId) { if (!token || !controllingServer || !hardwareDeviceId) {
store.dispatch(setTelemetryError("Missing socket auth info")); store.dispatch(setTelemetryError("Missing socket auth info"));
@ -72,7 +73,7 @@ export const connectSocket = () => {
console.log("Initializing socket connection..."); console.log("Initializing socket connection...");
socket = io( socket = io(
`${VECTOR_BASE_URL}/?dashboardId=deviceDashboardSocket&assetId=${hardwareDeviceId}`, `${SOCKET_BASE_URL}/?dashboardId=deviceDashboardSocket&assetId=${hardwareDeviceId}`,
{ {
transports: ["websocket"], transports: ["websocket"],
extraHeaders: { extraHeaders: {

152
store/paymentSlice.ts Normal file
View File

@ -0,0 +1,152 @@
import { BASE_URL } from "@/constants/config";
import api from "@/services/axiosClient";
import { toCamel } from "@/utils/Payments";
import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit";
import { AxiosError } from "axios";
type LastEmiStatus = "pending" | "completed";
export interface MyPlan {
noOfEmi: number | null;
totalAmount: number | null;
downPayment: number | null;
emiAmount: number | null;
totalEmi: number | null;
installmentPaid: number | null;
currentAmount: number | null;
}
export interface EmiDetails {
dueAmount: number | null;
totalAmountPaidInCurrentCycle: number | null;
dueDate: string | null;
status: LastEmiStatus | null;
advanceBalance: number | null;
pendingCycles: number | null;
totalPendingInstallments: number | null;
myPlan: MyPlan;
}
type LoadingState = "idle" | "pending" | "succeeded" | "failed";
export interface EmiDetailsState {
item?: EmiDetails;
loading: LoadingState;
error?: string;
lastFetchedAt?: number;
}
const initialState: EmiDetailsState = {
item: undefined,
loading: "idle",
error: undefined,
lastFetchedAt: undefined,
};
export interface MyPlanApi {
no_of_emi: number | null;
total_amount: number | null;
down_payment: number | null;
emi_amount: number | null;
total_emi: number | null;
installment_paid: number | null;
current_amount: number | null;
}
export interface EmiDetailsApi {
due_amount: number | null;
total_amount_paid_in_current_cycle: number | null;
due_date: string | null;
status: LastEmiStatus | null;
advance_balance: number | null;
pending_cycles: number | null;
total_pending_installments: number | null;
myPlain: MyPlanApi;
}
export interface EmiDetailsResponse {
success: boolean;
data: EmiDetailsApi[];
}
const mapEmiDetailsApiToModel = (apiObj: EmiDetailsApi): EmiDetails => ({
dueAmount: apiObj.due_amount,
totalAmountPaidInCurrentCycle: apiObj.total_amount_paid_in_current_cycle,
dueDate: apiObj.due_date,
status: apiObj.status,
advanceBalance: apiObj.advance_balance,
pendingCycles: apiObj.pending_cycles,
totalPendingInstallments: apiObj.total_pending_installments,
myPlan: {
noOfEmi: apiObj.myPlain.no_of_emi,
totalAmount: apiObj.myPlain.total_amount,
downPayment: apiObj.myPlain.down_payment,
emiAmount: apiObj.myPlain.emi_amount,
totalEmi: apiObj.myPlain.total_emi,
installmentPaid: apiObj.myPlain.installment_paid,
currentAmount: apiObj.myPlain.current_amount,
},
});
export const fetchEmiDetails = createAsyncThunk<
EmiDetails,
void,
{ rejectValue: string }
>("emiDetails/fetch", async (_: void, { rejectWithValue }) => {
try {
const res = await api.get(`${BASE_URL}}/api/v1/emi-details`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const json = res.data;
const first = json?.data?.[0];
if (!json?.success || !first) {
return rejectWithValue("No EMI details found");
}
return mapEmiDetailsApiToModel(first);
} catch (e: any) {
const err = e as AxiosError<any>;
if (err.code === "ERR_CANCELED") return rejectWithValue("Request aborted");
const msg =
err.response?.data?.message || err.message || "Something went wrong";
return rejectWithValue(msg);
}
});
const emiDetailsSlice = createSlice({
name: "emiDetails",
initialState,
reducers: {
clearEmiDetails(state) {
state.item = undefined;
state.error = undefined;
state.loading = "idle";
state.lastFetchedAt = undefined;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchEmiDetails.pending, (state) => {
state.loading = "pending";
state.error = undefined;
})
.addCase(
fetchEmiDetails.fulfilled,
(state, action: PayloadAction<EmiDetails>) => {
state.loading = "succeeded";
state.item = action.payload;
state.lastFetchedAt = Date.now();
}
)
.addCase(fetchEmiDetails.rejected, (state, action) => {
state.loading = "failed";
state.error = action.payload as string | undefined;
});
},
});
export const { clearEmiDetails } = emiDetailsSlice.actions;
export default emiDetailsSlice.reducer;

View File

@ -2,11 +2,13 @@ import { combineReducers } from "@reduxjs/toolkit";
import authreducer from "./authSlice"; import authreducer from "./authSlice";
import telemetryReducer from "./telemetrySlice"; import telemetryReducer from "./telemetrySlice";
import userReducer from "./userSlice"; import userReducer from "./userSlice";
import emiDetailsReducer from "./paymentSlice";
const rootReducer = combineReducers({ const rootReducer = combineReducers({
auth: authreducer, auth: authreducer,
telemetry: telemetryReducer, telemetry: telemetryReducer,
user: userReducer, user: userReducer,
payments: emiDetailsReducer,
}); });
export default rootReducer; export default rootReducer;

View File

@ -31,6 +31,13 @@ export const telemetrySlice = createSlice({
return { ...state, ...action.payload }; return { ...state, ...action.payload };
}, },
setTelemetryLoading: (state) => { setTelemetryLoading: (state) => {
state.loading = true;
state.SoH = null;
state.SoC = null;
state.chargingState = null;
state.lat = null;
state.lon = null;
state.totalDistance = null;
state.loading = true; state.loading = true;
state.error = null; state.error = null;
}, },

View File

@ -22,6 +22,7 @@ interface UserData {
interface Battery { interface Battery {
battery_id: string; battery_id: string;
device_id: string;
warranty_status: boolean; warranty_status: boolean;
battery_model: string; battery_model: string;
bms_id: string; bms_id: string;

12
utils/Payments.ts Normal file
View File

@ -0,0 +1,12 @@
export const toCamel = (obj: any): any => {
if (Array.isArray(obj)) return obj.map(toCamel);
if (obj && typeof obj === "object") {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [
k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
toCamel(v),
])
);
}
return obj;
};