diff --git a/app/user/edit_name.tsx b/app/user/edit_name.tsx index 0944b1e..9c0a8cd 100644 --- a/app/user/edit_name.tsx +++ b/app/user/edit_name.tsx @@ -16,7 +16,7 @@ import { Formik } from "formik"; import * as Yup from "yup"; import api from "@/services/axiosClient"; import { BASE_URL } from "@/constants/config"; -import { setUsername } from "@/store/userSlice"; +import { setUserData } from "@/store/userSlice"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { router } from "expo-router"; import { useSnackbar } from "@/contexts/Snackbar"; @@ -27,7 +27,9 @@ export default function EditName() { const dispatch = useDispatch(); const insets = useSafeAreaInsets(); const nameSchema = Yup.object().shape({ - name: Yup.string().required("Name is required"), + name: Yup.string() + .required("Name is required") + .max(57, "Name cannot exceed 57 characters"), }); const { showSnackbar } = useSnackbar(); const handleSave = async (values: { name: string }) => { @@ -37,7 +39,7 @@ export default function EditName() { mobile: data?.mobile, }); - dispatch(setUsername(values.name)); + dispatch(setUserData({ name: values.name })); showSnackbar("Name updated successfully", "success"); router.back(); } catch (error) { diff --git a/app/user/profile.tsx b/app/user/profile.tsx index f35d52d..525b818 100644 --- a/app/user/profile.tsx +++ b/app/user/profile.tsx @@ -10,32 +10,139 @@ import { import { MaterialIcons } from "@expo/vector-icons"; import LanguageModal from "@/components/Profile/LangaugeModal"; import { useSelector } from "react-redux"; -import { RootState } from "@/store"; +import { AppDispatch, RootState } from "@/store"; import EditIcon from "../../assets/icons/edit.svg"; import { router } from "expo-router"; +import { useDispatch } from "react-redux"; +import { logout } from "@/store/authSlice"; +import * as ImagePicker from "expo-image-picker"; +import { useSnackbar } from "@/contexts/Snackbar"; +import { AWS, BASE_URL, USER_PROFILE } from "@/constants/config"; +import { bytesToMB, handleUpload, updateUserProfile } from "@/utils/User"; +import api from "@/services/axiosClient"; +import { PresignedUrlDataItem } from "@/utils/User"; +import { setUserData } from "@/store/userSlice"; +import { Overlay } from "@/components/common/Overlay"; + +interface PresignedUrlResponse { + message: string; + data?: PresignedUrlDataItem[]; +} export default function ProfileScreen() { const [isLangaugeModalVisible, setLanguageModalVisible] = React.useState(false); - + const { showSnackbar } = useSnackbar(); const toggleLanguageModal = () => { setLanguageModalVisible(!isLangaugeModalVisible); }; + const [isUploading, setIsUploading] = React.useState(false); + const { data } = useSelector((state: RootState) => state.user); const userName = data?.name || "User"; const mobileNumber = data?.mobile || "Not provided"; + const userImageUrl = data?.profile_url; + const dispatch = useDispatch(); + const handleLogout = () => { + dispatch(logout()); + router.replace("/auth/login"); + }; + + const uploadImage = async (fileName: string, uri: string) => { + try { + setIsUploading(true); + const getPresignedUrl = `${BASE_URL}/api/v1/generate-presigned-urls`; + + const response = await api.post(getPresignedUrl, { + files: [fileName], + }); + + const uploadData = response?.data?.data?.[0]; + if (!uploadData) throw new Error("Presigned URL not received"); + + const { url, fields, originalFileName } = uploadData; + const presignedUrl: PresignedUrlDataItem = { + url, + fields, + originalFileName, + }; + await handleUpload(uri, presignedUrl); + + const region = AWS.REGION; + const bucketName = AWS.BUCKET_NAME; + const objectKey = presignedUrl.fields.key; + + const uploadedImageUrl = `https://s3.${region}.amazonaws.com/${bucketName}/${encodeURIComponent( + objectKey + )}`; + + console.log("Uploaded image URL:", uploadedImageUrl); + await updateUserProfile({ + profile_url: uploadedImageUrl, + mobile: mobileNumber, + }); + + dispatch(setUserData({ profile_url: uri })); + + showSnackbar("Image uploaded successfully", "success"); + } catch (error) { + console.error("Error uploading image:", error); + showSnackbar("Error uploading image", "error"); + } finally { + setIsUploading(false); + } + }; + + const handlePickImage = async () => { + let result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 1, + allowsEditing: true, + aspect: [1, 1], + }); + + if (!result.canceled) { + const { uri, fileSize } = result.assets[0]; + console.log(uri, "File size:", fileSize); + if (!fileSize) { + showSnackbar("Image size is not available", "error"); + return; + } + const size = bytesToMB(fileSize); + if (size > USER_PROFILE.MAX_IMAGE_SIZE_IN_MB) { + showSnackbar( + `Image size exceeds ${USER_PROFILE.MAX_IMAGE_SIZE_IN_MB}MB limit`, + "error" + ); + throw new Error( + `Image size exceeds ${USER_PROFILE.MAX_IMAGE_SIZE_IN_MB}MB limit` + ); + } + + const fileName = uri.split("/").pop() || "image.jpg"; + await uploadImage(fileName, uri); + } + }; return ( <> - + + handlePickImage()} + > @@ -49,6 +156,7 @@ export default function ProfileScreen() { onPress={() => { router.push("/user/edit_name"); }} + style={styles.edit_button} > @@ -71,13 +179,14 @@ export default function ProfileScreen() { {menuItem("About App")} - {menuItem("Logout")} + {menuItem("Logout", handleLogout)} setLanguageModalVisible(false)} visible={isLangaugeModalVisible} /> + ); } @@ -90,6 +199,13 @@ const menuItem = (title: string, onPress?: () => void) => ( ); const styles = StyleSheet.create({ + edit_button: { + position: "absolute", + bottom: 0, + right: 0, + width: 40, + height: 40, + }, container: { borderWidth: 1, flex: 1, diff --git a/components/common/Overlay.tsx b/components/common/Overlay.tsx new file mode 100644 index 0000000..9448bf7 --- /dev/null +++ b/components/common/Overlay.tsx @@ -0,0 +1,41 @@ +import { ActivityIndicator, Modal, StyleSheet, View } from "react-native"; + +interface OverlayProps { + isUploading: boolean; + transparent?: boolean; + animationType?: "fade" | "slide" | "none"; + statusBarTranslucent?: boolean; + size?: number; + color?: string; +} + +export const Overlay = ({ + isUploading, + size = 32, + color = "#fff", + transparent = true, + animationType = "fade", + statusBarTranslucent = true, +}: OverlayProps) => { + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + loaderOverlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.4)", + justifyContent: "center", + alignItems: "center", + }, +}); diff --git a/components/home/BatteryWarrantyCars.tsx b/components/home/BatteryWarrantyCars.tsx index 501f330..8db0a6f 100644 --- a/components/home/BatteryWarrantyCars.tsx +++ b/components/home/BatteryWarrantyCars.tsx @@ -3,22 +3,24 @@ import { View, Text, StyleSheet, Pressable } from "react-native"; import ChevronRight from "../../assets/icons/chevron_rightside.svg"; type Props = { - totalWarrantyYears: number; // in years - batteryPurchaseEpoch: number; // in seconds + warrantyStartDate: string; + warrantyEndDate: string; }; const BatteryWarrantyCard: React.FC = ({ - totalWarrantyYears, - batteryPurchaseEpoch, + warrantyStartDate, + warrantyEndDate, }) => { - const totalWarrantyMs = totalWarrantyYears * 365.25 * 24 * 60 * 60 * 1000; - const purchaseDate = new Date(batteryPurchaseEpoch * 1000); + const start = new Date(warrantyStartDate); + const end = new Date(warrantyEndDate); const now = new Date(); - const elapsed = now.getTime() - purchaseDate.getTime(); - const remaining = Math.max(totalWarrantyMs - elapsed, 0); - const progress = Math.min(elapsed / totalWarrantyMs, 1); - const remainingDate = new Date(remaining); + const totalDuration = end.getTime() - start.getTime(); + const elapsed = now.getTime() - start.getTime(); + const remaining = Math.max(end.getTime() - now.getTime(), 0); + + const progress = Math.min(elapsed / totalDuration, 1); + const yearsLeft = Math.floor(remaining / (365.25 * 24 * 60 * 60 * 1000)); const monthsLeft = Math.floor( (remaining % (365.25 * 24 * 60 * 60 * 1000)) / (30.44 * 24 * 60 * 60 * 1000) @@ -33,7 +35,9 @@ const BatteryWarrantyCard: React.FC = ({ Battery Warranty Left - {`${yearsLeft} years, ${monthsLeft} month${ + {`${yearsLeft} year${ + yearsLeft !== 1 ? "s" : "" + }, ${monthsLeft} month${ monthsLeft !== 1 ? "s" : "" }, ${daysLeft} day${daysLeft !== 1 ? "s" : ""}`} diff --git a/constants/config.ts b/constants/config.ts index 6da754a..3e4fd03 100644 --- a/constants/config.ts +++ b/constants/config.ts @@ -12,8 +12,11 @@ import DangerIcon from "../assets/icons/danger.svg"; import type { BmsState } from "./types"; import { useTranslation } from "react-i18next"; -export const BASE_URL = - "https://eae2-2400-80c0-2001-9c6-50f0-eda-dcd7-4167.ngrok-free.app"; +export const BASE_URL = "https://dev-driver-saathi-api.vecmocon.com"; + +// const SERVER_URL = +// "http://dev.vec-tr.ai:8089/?dashboardId=deviceDashboardSocket&assetId=V16000868651064644504"; +export const VECTOR_BASE_URL = "https://vec-tr.ai"; export const STORAGE_KEYS = { LANGUAGE: "userLanguage", @@ -92,8 +95,17 @@ export const SUPPORT = { EMAIL_BODY: "Hello,\n\nI need assistance with...", }; +export const USER_PROFILE = { + MAX_IMAGE_SIZE_IN_MB: 5, +}; + export const BMS_STATE_LABELS: Record = { 0: "Idle", 1: "Charging", [-1]: "Discharging", }; + +export const AWS = { + BUCKET_NAME: "battery-as-a-service", + REGION: "us-east-1", +}; diff --git a/services/socket.ts b/services/socket.ts index 516db8c..0f3d009 100644 --- a/services/socket.ts +++ b/services/socket.ts @@ -6,25 +6,82 @@ import { setTelemetryError, } from "../store/telemetrySlice"; import { BmsState } from "@/constants/types"; +import { BASE_URL, VECTOR_BASE_URL } from "@/constants/config"; +import api from "./axiosClient"; +import axios from "axios"; -const SERVER_URL = - "http://dev.vec-tr.ai:8089/?dashboardId=deviceDashboardSocket&assetId=V16000868651064644504"; -const TOKEN = - "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2NCIsImFjdGlvbiI6ImF1dGgiLCJ0b2tlbi12ZXJzaW9uIjowLCJpYXQiOjE3NTE2MDgwNjIsImV4cCI6MTc1MTY5NDQ2Mn0.-46Br0jSPwOTvkcDBTI05GJ1GavaAOOli6LEgkvjj3c"; +type TokenResponse = { + success: boolean; + message: string; + data: { + email: string; + token: string; + }; +}; let socket: Socket | null = null; +let token: string | null = null; +let controllingServer: string | null = null; + +const fetchToken = async (): Promise => { + try { + const response = await api.get( + `${BASE_URL}/api/v1/vec-token` + ); + + return response.data.data.token; + } catch (error) { + console.error("Error fetching token:", error); + throw error; + } +}; + +const fetchControllingServer = async (token: string): Promise => { + const hardwareDeviceId = store.getState().user.data?.batteries[0]?.battery_id; + + try { + if (!hardwareDeviceId) throw new Error("Missing hardwareDeviceId"); + + const response = await axios.get( + `${VECTOR_BASE_URL}/api/device-management/dashboard/get/hardwareDeviceId/${hardwareDeviceId.substring( + 1 + )}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + return response.data.trim(); + } catch (error) { + console.error("Error fetching controlling server:", error); + throw error; + } +}; + +export const connectSocket = () => { + const hardwareDeviceId = store.getState().user.data?.batteries[0]?.battery_id; + + if (!token || !controllingServer || !hardwareDeviceId) { + store.dispatch(setTelemetryError("Missing socket auth info")); + return; + } -export const initSocket = () => { store.dispatch(setTelemetryLoading()); console.log("Initializing socket connection..."); - socket = io(SERVER_URL, { - transports: ["websocket"], - extraHeaders: { - Authorization: `Bearer ${TOKEN}`, - controllingServer: "http://dev.vec-tr.ai:8082", - }, - reconnection: true, - }); + + socket = io( + `${VECTOR_BASE_URL}/?dashboardId=deviceDashboardSocket&assetId=${hardwareDeviceId}`, + { + transports: ["websocket"], + extraHeaders: { + Authorization: `Bearer ${token}`, + controllingServer: controllingServer, + }, + reconnection: true, + } + ); socket.on("connect", () => { console.log("Socket connected:", socket?.id); @@ -43,6 +100,17 @@ export const initSocket = () => { socket.on("dataUpdate", handleSocketData); }; +export const initSocket = async () => { + try { + token = await fetchToken(); + controllingServer = await fetchControllingServer(token); + connectSocket(); + } catch (err) { + console.log(err, ""); + store.dispatch(setTelemetryError("Initialization failed")); + } +}; + export const disconnectSocket = () => { if (socket) { socket.disconnect(); @@ -53,8 +121,10 @@ export const disconnectSocket = () => { const handleSocketData = (data: any) => { console.log("..."); try { + console.log(data.dataSeries.assetData, "dataSeries.assetData"); const SoH = - data.dataSeries.assetData[0].bms[0].bmsSpecific.ivecSpecific.soh ?? null; + data?.dataSeries?.assetData[0]?.bms[0]?.bmsSpecific?.ivecSpecific?.soh ?? + null; const SoC = data?.dataSeries?.assetData?.[0]?.bms?.[0]?.batterySoc ?? null; const currentMode = data?.dataSeries?.assetData?.[0]?.bms?.[0]?.bmsSpecific?.ivecSpecific diff --git a/store/userSlice.ts b/store/userSlice.ts index 49dae8a..8cdfb0c 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -17,6 +17,19 @@ interface UserData { email: string | null; profile_url: string | null; vehicles: Vehicle[]; + batteries: Battery[]; +} + +interface Battery { + battery_id: string; + warranty_status: boolean; + battery_model: string; + bms_id: string; + warranty_start_date: string; + warranty_end_date: string; + vim_id: string; + serial_no: string; + charger_uid: string; } interface UserState { @@ -42,6 +55,7 @@ export const getUserDetails = createAsyncThunk( console.log("User details fetched successfully:", response.data.data); return response.data.data; } else { + console.error("Failed to fetch user data:", response.data.message); return rejectWithValue("Failed to fetch user data"); } } catch (error: any) { @@ -49,6 +63,7 @@ export const getUserDetails = createAsyncThunk( error.response?.data?.message || error.message || "Something went wrong"; + console.log("Error fetching user details:", message); return rejectWithValue(message); } } @@ -63,11 +78,14 @@ const userSlice = createSlice({ state.error = null; state.loading = false; }, - setUsername: (state, action: PayloadAction) => { + setUserData: (state, action: PayloadAction>) => { if (state.data) { - state.data.name = action.payload; + state.data = { + ...state.data, + ...action.payload, + }; } else { - console.warn("Cannot set username, user data is not loaded"); + console.warn("Cannot update user data, user data is not loaded"); } }, }, @@ -91,5 +109,5 @@ const userSlice = createSlice({ }, }); -export const { clearUser, setUsername } = userSlice.actions; +export const { clearUser, setUserData } = userSlice.actions; export default userSlice.reducer; diff --git a/utils/User.ts b/utils/User.ts new file mode 100644 index 0000000..540213e --- /dev/null +++ b/utils/User.ts @@ -0,0 +1,127 @@ +import { BASE_URL } from "@/constants/config"; +import api from "@/services/axiosClient"; +import axios from "axios"; + +interface PresignedUrlField { + key: string; + "x-amz-meta-originalFileName": string; + bucket: string; + "X-Amz-Algorithm": string; + "X-Amz-Credential": string; + "X-Amz-Date": string; + Policy: string; + "X-Amz-Signature": string; +} + +export interface PresignedUrlDataItem { + url: string; + fields: PresignedUrlField; + originalFileName: string; +} + +export function bytesToMB(bytes: number) { + if (!bytes) return 0; + const MB = 1024 * 1024; + return bytes / MB; +} + +export const handleUpload = async ( + uri: string, + presignedUrl: PresignedUrlDataItem +) => { + const formData = new FormData(); + console.log("Presigned URL:", presignedUrl.url); + if (!presignedUrl) { + console.log("Presigned URL not found for file", uri); + throw new Error("Image upload failed."); + } + for (const [key, value] of Object.entries(presignedUrl.fields)) { + formData.append(key, value); + } + + formData.append("success_action_status", 201); + const fileType = getFileTypeFromUri(uri); + const fileName = extractFileName(uri); + + formData.append("file", { + uri: uri, + type: fileType, + name: fileName, + }); + + const instance = axios.create({ + baseURL: presignedUrl.url, + timeout: 5000, + }); + try { + await axios.post(presignedUrl.url, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + maxRedirects: 0, // optional, to detect redirection errors more explicitly + }); + } catch (error) { + console.error("Error uploading image:", error); + throw new Error("Image upload failed."); + } +}; + +export function extractFileName(filePath: string) { + let parts = filePath.split("/"); + return parts[parts.length - 1]; +} + +export const getFileTypeFromUri = (uri: string): string => { + const extension = uri.split(".").pop()?.toLowerCase() || ""; + + const mimeTypes: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + bmp: "image/bmp", + svg: "image/svg+xml", + webp: "image/webp", + txt: "text/plain", + pdf: "application/pdf", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + mp4: "video/mp4", + mp3: "audio/mpeg", + wav: "audio/wav", + }; + + return mimeTypes[extension] || "application/octet-stream"; +}; + +export const updateUserProfile = ({ + mobile, + name, + profile_url, +}: { + mobile: string; + name?: string; + profile_url?: string; +}) => { + if (!mobile) { + throw new Error("Mobile number is required"); + } + + const payload: { mobile: string; name?: string; profile_url?: string } = { + mobile, + }; + + if (typeof name === "string" && name.trim() !== "") { + payload.name = name; + } + + if (typeof profile_url === "string" && profile_url.trim() !== "") { + payload.profile_url = profile_url; + } + + return api.put(`${BASE_URL}/api/v1/update-user-information`, payload); +};