Complete user profile and socket connection

feature/app-setup
vinay kumar 2025-07-10 13:48:27 +05:30
parent b7ce82b86e
commit f85f43f968
8 changed files with 429 additions and 39 deletions

View File

@ -16,7 +16,7 @@ import { Formik } from "formik";
import * as Yup from "yup"; import * as Yup from "yup";
import api from "@/services/axiosClient"; import api from "@/services/axiosClient";
import { BASE_URL } from "@/constants/config"; 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { router } from "expo-router"; import { router } from "expo-router";
import { useSnackbar } from "@/contexts/Snackbar"; import { useSnackbar } from "@/contexts/Snackbar";
@ -27,7 +27,9 @@ export default function EditName() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const nameSchema = Yup.object().shape({ 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 { showSnackbar } = useSnackbar();
const handleSave = async (values: { name: string }) => { const handleSave = async (values: { name: string }) => {
@ -37,7 +39,7 @@ export default function EditName() {
mobile: data?.mobile, mobile: data?.mobile,
}); });
dispatch(setUsername(values.name)); dispatch(setUserData({ name: values.name }));
showSnackbar("Name updated successfully", "success"); showSnackbar("Name updated successfully", "success");
router.back(); router.back();
} catch (error) { } catch (error) {

View File

@ -10,32 +10,139 @@ import {
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import LanguageModal from "@/components/Profile/LangaugeModal"; import LanguageModal from "@/components/Profile/LangaugeModal";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { RootState } from "@/store"; import { AppDispatch, RootState } from "@/store";
import EditIcon from "../../assets/icons/edit.svg"; import EditIcon from "../../assets/icons/edit.svg";
import { router } from "expo-router"; 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() { export default function ProfileScreen() {
const [isLangaugeModalVisible, setLanguageModalVisible] = const [isLangaugeModalVisible, setLanguageModalVisible] =
React.useState(false); React.useState(false);
const { showSnackbar } = useSnackbar();
const toggleLanguageModal = () => { const toggleLanguageModal = () => {
setLanguageModalVisible(!isLangaugeModalVisible); setLanguageModalVisible(!isLangaugeModalVisible);
}; };
const [isUploading, setIsUploading] = React.useState(false);
const { data } = useSelector((state: RootState) => state.user); const { data } = useSelector((state: RootState) => state.user);
const userName = data?.name || "User"; const userName = data?.name || "User";
const mobileNumber = data?.mobile || "Not provided"; const mobileNumber = data?.mobile || "Not provided";
const userImageUrl = data?.profile_url;
const dispatch = useDispatch<AppDispatch>();
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<PresignedUrlResponse>(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 ( return (
<> <>
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
<Image <Image
source={require("../../assets/images/user_image.jpg")} source={
userImageUrl
? { uri: userImageUrl }
: require("@/assets/images/user_image.jpg")
}
style={styles.avatar} style={styles.avatar}
/> />
<TouchableOpacity style={styles.editAvatar}>
<TouchableOpacity
style={styles.editAvatar}
onPress={() => handlePickImage()}
>
<EditIcon /> <EditIcon />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -49,6 +156,7 @@ export default function ProfileScreen() {
onPress={() => { onPress={() => {
router.push("/user/edit_name"); router.push("/user/edit_name");
}} }}
style={styles.edit_button}
> >
<MaterialIcons name="edit" size={20} color="#555C70" /> <MaterialIcons name="edit" size={20} color="#555C70" />
</TouchableOpacity> </TouchableOpacity>
@ -71,13 +179,14 @@ export default function ProfileScreen() {
<View style={styles.card}> <View style={styles.card}>
{menuItem("About App")} {menuItem("About App")}
<View style={styles.divider} /> <View style={styles.divider} />
{menuItem("Logout")} {menuItem("Logout", handleLogout)}
</View> </View>
</ScrollView> </ScrollView>
<LanguageModal <LanguageModal
onClose={() => setLanguageModalVisible(false)} onClose={() => setLanguageModalVisible(false)}
visible={isLangaugeModalVisible} visible={isLangaugeModalVisible}
/> />
<Overlay isUploading={isUploading} />
</> </>
); );
} }
@ -90,6 +199,13 @@ const menuItem = (title: string, onPress?: () => void) => (
); );
const styles = StyleSheet.create({ const styles = StyleSheet.create({
edit_button: {
position: "absolute",
bottom: 0,
right: 0,
width: 40,
height: 40,
},
container: { container: {
borderWidth: 1, borderWidth: 1,
flex: 1, flex: 1,

View File

@ -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 (
<Modal
visible={isUploading}
transparent={transparent}
animationType={animationType}
statusBarTranslucent={statusBarTranslucent}
>
<View style={styles.loaderOverlay}>
<ActivityIndicator size={size} color={color} />
</View>
</Modal>
);
};
const styles = StyleSheet.create({
loaderOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
justifyContent: "center",
alignItems: "center",
},
});

View File

@ -3,22 +3,24 @@ import { View, Text, StyleSheet, Pressable } from "react-native";
import ChevronRight from "../../assets/icons/chevron_rightside.svg"; import ChevronRight from "../../assets/icons/chevron_rightside.svg";
type Props = { type Props = {
totalWarrantyYears: number; // in years warrantyStartDate: string;
batteryPurchaseEpoch: number; // in seconds warrantyEndDate: string;
}; };
const BatteryWarrantyCard: React.FC<Props> = ({ const BatteryWarrantyCard: React.FC<Props> = ({
totalWarrantyYears, warrantyStartDate,
batteryPurchaseEpoch, warrantyEndDate,
}) => { }) => {
const totalWarrantyMs = totalWarrantyYears * 365.25 * 24 * 60 * 60 * 1000; const start = new Date(warrantyStartDate);
const purchaseDate = new Date(batteryPurchaseEpoch * 1000); const end = new Date(warrantyEndDate);
const now = new Date(); 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 yearsLeft = Math.floor(remaining / (365.25 * 24 * 60 * 60 * 1000));
const monthsLeft = Math.floor( const monthsLeft = Math.floor(
(remaining % (365.25 * 24 * 60 * 60 * 1000)) / (30.44 * 24 * 60 * 60 * 1000) (remaining % (365.25 * 24 * 60 * 60 * 1000)) / (30.44 * 24 * 60 * 60 * 1000)
@ -33,7 +35,9 @@ const BatteryWarrantyCard: React.FC<Props> = ({
<View style={styles.textColumn}> <View style={styles.textColumn}>
<Text style={styles.title}>Battery Warranty Left</Text> <Text style={styles.title}>Battery Warranty Left</Text>
<Text style={styles.time}> <Text style={styles.time}>
{`${yearsLeft} years, ${monthsLeft} month${ {`${yearsLeft} year${
yearsLeft !== 1 ? "s" : ""
}, ${monthsLeft} month${
monthsLeft !== 1 ? "s" : "" monthsLeft !== 1 ? "s" : ""
}, ${daysLeft} day${daysLeft !== 1 ? "s" : ""}`} }, ${daysLeft} day${daysLeft !== 1 ? "s" : ""}`}
</Text> </Text>

View File

@ -12,8 +12,11 @@ import DangerIcon from "../assets/icons/danger.svg";
import type { BmsState } from "./types"; import type { BmsState } from "./types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const BASE_URL = export const BASE_URL = "https://dev-driver-saathi-api.vecmocon.com";
"https://eae2-2400-80c0-2001-9c6-50f0-eda-dcd7-4167.ngrok-free.app";
// 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 = { export const STORAGE_KEYS = {
LANGUAGE: "userLanguage", LANGUAGE: "userLanguage",
@ -92,8 +95,17 @@ export const SUPPORT = {
EMAIL_BODY: "Hello,\n\nI need assistance with...", EMAIL_BODY: "Hello,\n\nI need assistance with...",
}; };
export const USER_PROFILE = {
MAX_IMAGE_SIZE_IN_MB: 5,
};
export const BMS_STATE_LABELS: Record<BmsState, string> = { export const BMS_STATE_LABELS: Record<BmsState, string> = {
0: "Idle", 0: "Idle",
1: "Charging", 1: "Charging",
[-1]: "Discharging", [-1]: "Discharging",
}; };
export const AWS = {
BUCKET_NAME: "battery-as-a-service",
REGION: "us-east-1",
};

View File

@ -6,25 +6,82 @@ 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 api from "./axiosClient";
import axios from "axios";
const SERVER_URL = type TokenResponse = {
"http://dev.vec-tr.ai:8089/?dashboardId=deviceDashboardSocket&assetId=V16000868651064644504"; success: boolean;
const TOKEN = message: string;
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2NCIsImFjdGlvbiI6ImF1dGgiLCJ0b2tlbi12ZXJzaW9uIjowLCJpYXQiOjE3NTE2MDgwNjIsImV4cCI6MTc1MTY5NDQ2Mn0.-46Br0jSPwOTvkcDBTI05GJ1GavaAOOli6LEgkvjj3c"; data: {
email: string;
token: string;
};
};
let socket: Socket | null = null; let socket: Socket | null = null;
let token: string | null = null;
let controllingServer: string | null = null;
const fetchToken = async (): Promise<string> => {
try {
const response = await api.get<TokenResponse>(
`${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<string> => {
const hardwareDeviceId = store.getState().user.data?.batteries[0]?.battery_id;
try {
if (!hardwareDeviceId) throw new Error("Missing hardwareDeviceId");
const response = await axios.get<string>(
`${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()); store.dispatch(setTelemetryLoading());
console.log("Initializing socket connection..."); console.log("Initializing socket connection...");
socket = io(SERVER_URL, {
transports: ["websocket"], socket = io(
extraHeaders: { `${VECTOR_BASE_URL}/?dashboardId=deviceDashboardSocket&assetId=${hardwareDeviceId}`,
Authorization: `Bearer ${TOKEN}`, {
controllingServer: "http://dev.vec-tr.ai:8082", transports: ["websocket"],
}, extraHeaders: {
reconnection: true, Authorization: `Bearer ${token}`,
}); controllingServer: controllingServer,
},
reconnection: true,
}
);
socket.on("connect", () => { socket.on("connect", () => {
console.log("Socket connected:", socket?.id); console.log("Socket connected:", socket?.id);
@ -43,6 +100,17 @@ export const initSocket = () => {
socket.on("dataUpdate", handleSocketData); 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 = () => { export const disconnectSocket = () => {
if (socket) { if (socket) {
socket.disconnect(); socket.disconnect();
@ -53,8 +121,10 @@ export const disconnectSocket = () => {
const handleSocketData = (data: any) => { const handleSocketData = (data: any) => {
console.log("..."); console.log("...");
try { try {
console.log(data.dataSeries.assetData, "dataSeries.assetData");
const SoH = 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 SoC = data?.dataSeries?.assetData?.[0]?.bms?.[0]?.batterySoc ?? null;
const currentMode = const currentMode =
data?.dataSeries?.assetData?.[0]?.bms?.[0]?.bmsSpecific?.ivecSpecific data?.dataSeries?.assetData?.[0]?.bms?.[0]?.bmsSpecific?.ivecSpecific

View File

@ -17,6 +17,19 @@ interface UserData {
email: string | null; email: string | null;
profile_url: string | null; profile_url: string | null;
vehicles: Vehicle[]; 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 { interface UserState {
@ -42,6 +55,7 @@ export const getUserDetails = createAsyncThunk<UserData>(
console.log("User details fetched successfully:", response.data.data); console.log("User details fetched successfully:", response.data.data);
return response.data.data; return response.data.data;
} else { } else {
console.error("Failed to fetch user data:", response.data.message);
return rejectWithValue("Failed to fetch user data"); return rejectWithValue("Failed to fetch user data");
} }
} catch (error: any) { } catch (error: any) {
@ -49,6 +63,7 @@ export const getUserDetails = createAsyncThunk<UserData>(
error.response?.data?.message || error.response?.data?.message ||
error.message || error.message ||
"Something went wrong"; "Something went wrong";
console.log("Error fetching user details:", message);
return rejectWithValue(message); return rejectWithValue(message);
} }
} }
@ -63,11 +78,14 @@ const userSlice = createSlice({
state.error = null; state.error = null;
state.loading = false; state.loading = false;
}, },
setUsername: (state, action: PayloadAction<string>) => { setUserData: (state, action: PayloadAction<Partial<UserData>>) => {
if (state.data) { if (state.data) {
state.data.name = action.payload; state.data = {
...state.data,
...action.payload,
};
} else { } 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; export default userSlice.reducer;

127
utils/User.ts Normal file
View File

@ -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<string, string> = {
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);
};