Khởi tạo ban đầu

This commit is contained in:
Tran Anh Tuan
2025-11-28 16:59:57 +07:00
parent 2911be97b2
commit 4ba46a7df2
131 changed files with 28066 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import { ThemedText } from "@/components/themed-text";
import { useAppTheme } from "@/hooks/use-app-theme";
import { View } from "react-native";
interface DescriptionProps {
title?: string;
description?: string;
}
export const Description = ({
title = "",
description = "",
}: DescriptionProps) => {
const { colors } = useAppTheme();
return (
<View className="flex-row gap-2 ">
<ThemedText
style={{ color: colors.textSecondary, fontSize: 16 }}
>
{title}:
</ThemedText>
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
</View>
);
};

View File

@@ -0,0 +1,126 @@
import { useAppTheme } from "@/hooks/use-app-theme";
import { useI18n } from "@/hooks/use-i18n";
import { convertToDMS, kmhToKnot } from "@/utils/geom";
import { MaterialIcons } from "@expo/vector-icons";
import { useEffect, useRef, useState } from "react";
import { Animated, TouchableOpacity, View } from "react-native";
import ButtonCreateNewHaulOrTrip from "../ButtonCreateNewHaulOrTrip";
import { Description } from "./Description";
type GPSInfoPanelProps = {
gpsData: Model.GPSResponse | undefined;
};
const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
const [isExpanded, setIsExpanded] = useState(true);
const [panelHeight, setPanelHeight] = useState(0);
const translateY = useRef(new Animated.Value(0)).current;
const blockBottom = useRef(new Animated.Value(0)).current;
const { t } = useI18n();
const { colors, styles } = useAppTheme();
useEffect(() => {
Animated.timing(translateY, {
toValue: isExpanded ? 0 : 200, // Dịch chuyển xuống 200px khi thu gọn
duration: 500,
useNativeDriver: true,
}).start();
}, [isExpanded]);
useEffect(() => {
const targetBottom = isExpanded ? panelHeight + 12 : 10;
Animated.timing(blockBottom, {
toValue: targetBottom,
duration: 500,
useNativeDriver: false,
}).start();
}, [isExpanded, panelHeight, blockBottom]);
const togglePanel = () => {
setIsExpanded(!isExpanded);
};
return (
<>
{/* Khối hình vuông */}
<Animated.View
style={{
position: "absolute",
bottom: blockBottom,
left: 5,
borderRadius: 4,
zIndex: 30,
}}
>
<ButtonCreateNewHaulOrTrip gpsData={gpsData} />
</Animated.View>
<Animated.View
style={[
styles.card,
{
transform: [{ translateY }],
backgroundColor: colors.card,
borderRadius: 0,
},
]}
className="absolute bottom-0 gap-5 right-0 px-4 pt-12 pb-2 left-0 h-auto w-full rounded-t-xl shadow-md"
onLayout={(event) => setPanelHeight(event.nativeEvent.layout.height)}
>
{/* Nút toggle ở top-right */}
<TouchableOpacity
onPress={togglePanel}
className="absolute top-2 right-2 z-10 rounded-full p-1"
style={{ backgroundColor: colors.card }}
>
<MaterialIcons
name={isExpanded ? "close" : "close"}
size={20}
color={colors.icon}
/>
</TouchableOpacity>
<View className="flex-row justify-between">
<View className="flex-1">
<Description
title={t("home.latitude")}
description={convertToDMS(gpsData?.lat ?? 0, true)}
/>
</View>
<View className="flex-1">
<Description
title={t("home.longitude")}
description={convertToDMS(gpsData?.lon ?? 0, false)}
/>
</View>
</View>
<View className="flex-row justify-between">
<View className="flex-1">
<Description
title={t("home.speed")}
description={`${kmhToKnot(gpsData?.s ?? 0).toString()} knot`}
/>
</View>
<View className="flex-1">
<Description
title={t("home.heading")}
description={`${gpsData?.h ?? 0}°`}
/>
</View>
</View>
</Animated.View>
{/* Nút floating để mở lại panel khi thu gọn */}
{!isExpanded && (
<TouchableOpacity
onPress={togglePanel}
className="absolute bottom-5 right-2 z-20 rounded-full p-2 shadow-lg"
style={{ backgroundColor: colors.card }}
>
<MaterialIcons name="info-outline" size={24} color={colors.icon} />
</TouchableOpacity>
)}
</>
);
};
export default GPSInfoPanel;

View File

@@ -0,0 +1,148 @@
import { ANDROID_PLATFORM } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import { getPolygonCenter } from "@/utils/polyline";
import React, { useRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { MapMarker, Marker, Polygon } from "react-native-maps";
export interface PolygonWithLabelProps {
coordinates: {
latitude: number;
longitude: number;
}[];
label?: string;
content?: string;
fillColor?: string;
strokeColor?: string;
strokeWidth?: number;
zIndex?: number;
zoomLevel?: number;
}
/**
* Component render Polygon kèm Label/Text ở giữa
*/
export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
coordinates,
label,
content,
fillColor = "rgba(16, 85, 201, 0.6)",
strokeColor = "rgba(16, 85, 201, 0.8)",
strokeWidth = 2,
zIndex = 50,
zoomLevel = 10,
}) => {
if (!coordinates || coordinates.length < 3) {
return null;
}
const platform = usePlatform();
const markerRef = useRef<MapMarker>(null);
const centerPoint = getPolygonCenter(coordinates);
// Tính font size dựa trên zoom level
// Zoom càng thấp (xa ra) thì font size càng nhỏ
const calculateFontSize = (baseSize: number) => {
const baseZoom = 10;
// Giảm scale factor để text không quá to khi zoom out
const scaleFactor = Math.pow(2, (zoomLevel - baseZoom) * 0.3);
return Math.max(baseSize * scaleFactor, 5); // Tối thiểu 5px
};
const labelFontSize = calculateFontSize(12);
const contentFontSize = calculateFontSize(10);
// console.log("zoom level: ", zoomLevel);
const paddingScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.2), 0.5);
const minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
return (
<>
<Polygon
coordinates={coordinates}
fillColor={fillColor}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
zIndex={zIndex}
/>
{label && (
<Marker
ref={markerRef}
coordinate={centerPoint}
zIndex={50}
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
anchor={{ x: 0.5, y: 0.5 }}
title={platform === ANDROID_PLATFORM ? label : undefined}
description={platform === ANDROID_PLATFORM ? content : undefined}
>
<View style={styles.markerContainer}>
<View
style={[
{
paddingHorizontal: 5 * paddingScale,
paddingVertical: 5 * paddingScale,
minWidth: 80,
maxWidth: 150 * minWidthScale,
},
]}
>
<Text
style={[styles.labelText, { fontSize: labelFontSize }]}
numberOfLines={2}
>
{label}
</Text>
{content && (
<Text
style={[
styles.contentText,
{ fontSize: contentFontSize, marginTop: 2 * paddingScale },
]}
numberOfLines={2}
>
{content}
</Text>
)}
</View>
</View>
</Marker>
)}
</>
);
};
const styles = StyleSheet.create({
markerContainer: {
alignItems: "center",
justifyContent: "center",
},
labelContainer: {
backgroundColor: "rgba(16, 85, 201, 0.95)",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 18,
borderWidth: 2,
borderColor: "#fff",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 5,
elevation: 8,
minWidth: 80,
maxWidth: 250,
},
labelText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
letterSpacing: 0.3,
textAlign: "center",
},
contentText: {
color: "#fff",
fontSize: 11,
fontWeight: "600",
letterSpacing: 0.2,
textAlign: "center",
opacity: 0.95,
},
});

View File

@@ -0,0 +1,112 @@
import { ANDROID_PLATFORM } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import {
calculateTotalDistance,
getMiddlePointOfPolyline,
} from "@/utils/polyline";
import React, { useRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { MapMarker, Marker, Polyline } from "react-native-maps";
export interface PolylineWithLabelProps {
coordinates: {
latitude: number;
longitude: number;
}[];
label?: string;
content?: string;
strokeColor?: string;
strokeWidth?: number;
showDistance?: boolean;
zIndex?: number;
}
/**
* Component render Polyline kèm Label/Text ở giữa
*/
export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
coordinates,
label,
content,
strokeColor = "#FF5733",
strokeWidth = 4,
showDistance = false,
zIndex = 50,
}) => {
if (!coordinates || coordinates.length < 2) {
return null;
}
const middlePoint = getMiddlePointOfPolyline(coordinates);
const distance = calculateTotalDistance(coordinates);
const platform = usePlatform();
const markerRef = useRef<MapMarker>(null);
let displayText = label || "";
if (showDistance) {
displayText += displayText
? ` (${distance.toFixed(2)}km)`
: `${distance.toFixed(2)}km`;
}
return (
<>
<Polyline
coordinates={coordinates}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
zIndex={zIndex}
/>
{displayText && (
<Marker
ref={markerRef}
coordinate={middlePoint}
zIndex={zIndex + 10}
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
anchor={{ x: 0.5, y: 0.5 }}
title={platform === ANDROID_PLATFORM ? label : undefined}
description={platform === ANDROID_PLATFORM ? content : undefined}
>
<View style={styles.markerContainer}>
<View style={styles.labelContainer}>
<Text
style={styles.labelText}
numberOfLines={2}
adjustsFontSizeToFit
>
{displayText}
</Text>
</View>
</View>
</Marker>
)}
</>
);
};
const styles = StyleSheet.create({
markerContainer: {
alignItems: "center",
justifyContent: "center",
},
labelContainer: {
backgroundColor: "rgba(255, 87, 51, 0.95)",
paddingHorizontal: 5,
paddingVertical: 5,
borderRadius: 18,
borderWidth: 1,
borderColor: "#fff",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 5,
elevation: 8,
minWidth: 80,
maxWidth: 180,
},
labelText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
letterSpacing: 0.3,
textAlign: "center",
},
});

View File

@@ -0,0 +1,249 @@
import {
queryDeleteSos,
queryGetSos,
querySendSosMessage,
} from "@/controller/DeviceController";
import { useI18n } from "@/hooks/use-i18n";
import { showErrorToast } from "@/services/toast_service";
import { sosMessage } from "@/utils/sosUtils";
import { MaterialIcons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";
import IconButton from "../IconButton";
import Select from "../Select";
import Modal from "../ui/modal";
import { useThemeColor } from "@/hooks/use-theme-color";
const SosButton = () => {
const [sosData, setSosData] = useState<Model.SosResponse | null>();
const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
const [selectedSosMessage, setSelectedSosMessage] = useState<number | null>(
null
);
const [customMessage, setCustomMessage] = useState("");
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const { t } = useI18n();
// Theme colors
const textColor = useThemeColor({}, 'text');
const borderColor = useThemeColor({}, 'border');
const errorColor = useThemeColor({}, 'error');
const backgroundColor = useThemeColor({}, 'background');
// Dynamic styles
const styles = SosButtonStyles(textColor, borderColor, errorColor, backgroundColor);
const sosOptions = [
...sosMessage.map((msg) => ({
ma: msg.ma,
moTa: msg.moTa,
label: msg.moTa,
value: msg.ma,
})),
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
];
const getSosData = async () => {
try {
const response = await queryGetSos();
// console.log("SoS ResponseL: ", response);
setSosData(response.data);
} catch (error) {
console.error("Failed to fetch SOS data:", error);
}
};
useEffect(() => {
getSosData();
}, []);
const validateForm = () => {
const newErrors: { [key: string]: string } = {};
if (selectedSosMessage === 999 && customMessage.trim() === "") {
newErrors.customMessage = t("home.sos.statusRequired");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleConfirmSos = async () => {
if (!validateForm()) {
console.log("Form chưa validate");
return; // Không đóng modal nếu validate fail
}
let messageToSend = "";
if (selectedSosMessage === 999) {
messageToSend = customMessage.trim();
} else {
const selectedOption = sosOptions.find(
(opt) => opt.ma === selectedSosMessage
);
messageToSend = selectedOption ? selectedOption.moTa : "";
}
// Gửi dữ liệu đi
await sendSosMessage(messageToSend);
// Đóng modal và reset form sau khi gửi thành công
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
};
const handleClickButton = async (isActive: boolean) => {
console.log("Is Active: ", isActive);
if (isActive) {
const resp = await queryDeleteSos();
if (resp.status === 200) {
await getSosData();
}
} else {
setSelectedSosMessage(11); // Mặc định chọn lý do ma: 11
setShowConfirmSosDialog(true);
}
};
const sendSosMessage = async (message: string) => {
try {
const resp = await querySendSosMessage(message);
if (resp.status === 200) {
await getSosData();
}
} catch (error) {
console.error("Error when send sos: ", error);
showErrorToast(t("home.sos.sendError"));
}
};
return (
<>
<IconButton
icon={<MaterialIcons name="warning" size={20} color="white" />}
type="danger"
size="middle"
onPress={() => handleClickButton(sosData?.active || false)}
style={{ borderRadius: 20 }}
>
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
</IconButton>
<Modal
open={showConfirmSosDialog}
onCancel={() => {
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
}}
okText={t("home.sos.confirm")}
cancelText={t("home.sos.cancel")}
title={t("home.sos.title")}
centered
onOk={handleConfirmSos}
>
{/* Select Nội dung SOS */}
<View style={styles.formGroup}>
<Text style={styles.label}>{t("home.sos.content")}</Text>
<Select
value={selectedSosMessage ?? undefined}
options={sosOptions}
placeholder={t("home.sos.selectReason")}
onChange={(value) => {
setSelectedSosMessage(value as number);
// Clear custom message nếu chọn khác lý do
if (value !== 999) {
setCustomMessage("");
}
// Clear error if exists
if (errors.sosMessage) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.sosMessage;
return newErrors;
});
}
}}
showSearch={false}
style={[errors.sosMessage ? styles.errorBorder : undefined]}
/>
{errors.sosMessage && (
<Text style={styles.errorText}>{errors.sosMessage}</Text>
)}
</View>
{/* Input Custom Message nếu chọn "Khác" */}
{selectedSosMessage === 999 && (
<View style={styles.formGroup}>
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
<TextInput
style={[
styles.input,
errors.customMessage ? styles.errorInput : {},
]}
placeholder={t("home.sos.enterStatus")}
placeholderTextColor={textColor + '99'} // Add transparency
value={customMessage}
onChangeText={(text) => {
setCustomMessage(text);
if (text.trim() !== "") {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.customMessage;
return newErrors;
});
}
}}
multiline
numberOfLines={4}
/>
{errors.customMessage && (
<Text style={styles.errorText}>{errors.customMessage}</Text>
)}
</View>
)}
</Modal>
</>
);
};
const SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
formGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
marginBottom: 8,
color: textColor,
},
errorBorder: {
borderColor: errorColor,
},
input: {
borderWidth: 1,
borderColor: borderColor,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 14,
color: textColor,
backgroundColor: backgroundColor,
textAlignVertical: "top",
},
errorInput: {
borderColor: errorColor,
},
errorText: {
color: errorColor,
fontSize: 12,
marginTop: 4,
},
});
export default SosButton;