thêm chức năng Sos và thêm glustack-ui

This commit is contained in:
Tran Anh Tuan
2025-11-04 16:24:54 +07:00
parent e535aaa1e8
commit 2137925ba9
33 changed files with 5533 additions and 171 deletions

View File

@@ -0,0 +1,17 @@
import { Text, View } from "react-native";
interface DescriptionProps {
title?: string;
description?: string;
}
export const Description = ({
title = "",
description = "",
}: DescriptionProps) => {
return (
<View className="flex-row gap-2 ">
<Text className="opacity-50 text-lg">{title}:</Text>
<Text className="text-lg">{description}</Text>
</View>
);
};

View File

@@ -0,0 +1,113 @@
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 { Description } from "./Description";
type GPSInfoPanelProps = {
gpsData: Model.GPSResonse | 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;
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,
width: 48,
height: 48,
backgroundColor: "blue",
borderRadius: 4,
zIndex: 30,
}}
/>
<Animated.View
style={{
transform: [{ translateY }],
}}
className="absolute bottom-0 gap-3 right-0 p-3 left-0 h-auto w-full rounded-t-xl bg-white 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 bg-white rounded-full p-1 shadow-sm"
>
<MaterialIcons
name={isExpanded ? "expand-more" : "expand-less"}
size={20}
color="#666"
/>
</TouchableOpacity>
<View className="flex-row justify-between">
<View className="flex-1">
<Description
title="Kinh độ"
description={convertToDMS(gpsData?.lat ?? 0, true)}
/>
</View>
<View className="flex-1">
<Description
title="Vĩ độ"
description={convertToDMS(gpsData?.lon ?? 0, false)}
/>
</View>
</View>
<View className="flex-row justify-between">
<View className="flex-1">
<Description
title="Tốc độ"
description={`${kmhToKnot(gpsData?.s ?? 0).toString()} knot`}
/>
</View>
<View className="flex-1">
<Description title="Hướng" 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 bg-white rounded-full p-2 shadow-lg"
>
<MaterialIcons name="info-outline" size={24} />
</TouchableOpacity>
)}
</>
);
};
export default GPSInfoPanel;

View File

@@ -1,7 +1,9 @@
import { ANDROID_PLATFORM } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import { getPolygonCenter } from "@/utils/polyline";
import React from "react";
import React, { useRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Marker, Polygon } from "react-native-maps";
import { MapMarker, Marker, Polygon } from "react-native-maps";
export interface PolygonWithLabelProps {
coordinates: {
@@ -33,6 +35,8 @@ export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
if (!coordinates || coordinates.length < 3) {
return null;
}
const platform = usePlatform();
const markerRef = useRef<MapMarker>(null);
const centerPoint = getPolygonCenter(coordinates);
@@ -51,8 +55,7 @@ export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
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);
// console.log("Min Width Scale: ", minWidthScale);
markerRef.current?.showCallout();
return (
<>
<Polygon
@@ -64,10 +67,13 @@ export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
/>
{label && (
<Marker
ref={markerRef}
coordinate={centerPoint}
zIndex={50}
tracksViewChanges={false}
anchor={{ x: 0.5, y: 0.5 }}
title={platform === ANDROID_PLATFORM ? label : undefined}
description={platform === ANDROID_PLATFORM ? content : undefined}
>
<View style={styles.markerContainer}>
<View

View File

@@ -1,10 +1,12 @@
import { ANDROID_PLATFORM } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import {
calculateTotalDistance,
getMiddlePointOfPolyline,
} from "@/utils/polyline";
import React from "react";
import React, { useRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Marker, Polyline } from "react-native-maps";
import { MapMarker, Marker, Polyline } from "react-native-maps";
export interface PolylineWithLabelProps {
coordinates: {
@@ -12,6 +14,7 @@ export interface PolylineWithLabelProps {
longitude: number;
}[];
label?: string;
content?: string;
strokeColor?: string;
strokeWidth?: number;
showDistance?: boolean;
@@ -24,6 +27,7 @@ export interface PolylineWithLabelProps {
export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
coordinates,
label,
content,
strokeColor = "#FF5733",
strokeWidth = 4,
showDistance = false,
@@ -35,14 +39,15 @@ export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
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`;
}
markerRef.current?.showCallout();
return (
<>
<Polyline
@@ -53,10 +58,13 @@ export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
/>
{displayText && (
<Marker
ref={markerRef}
coordinate={middlePoint}
zIndex={zIndex + 10}
tracksViewChanges={false}
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}>

View File

@@ -0,0 +1,385 @@
import { showToastError } from "@/config";
import {
queryDeleteSos,
queryGetSos,
querySendSosMessage,
} from "@/controller/DeviceController";
import { sosMessage } from "@/utils/sosUtils";
import { MaterialIcons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import {
FlatList,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { Button, ButtonText } from "../ui/gluestack-ui-provider/button";
import {
Modal,
ModalBackdrop,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "../ui/gluestack-ui-provider/modal";
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 [showDropdown, setShowDropdown] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const sosOptions = [
...sosMessage.map((msg) => ({ ma: msg.ma, moTa: msg.moTa })),
{ ma: 999, moTa: "Khác" },
];
const getSosData = async () => {
try {
const response = await queryGetSos();
setSosData(response.data);
} catch (error) {
console.error("Failed to fetch SOS data:", error);
}
};
useEffect(() => {
getSosData();
}, []);
const validateForm = () => {
const newErrors: { [key: string]: string } = {};
// Không cần validate sosMessage vì luôn có default value (11)
if (selectedSosMessage === 999 && customMessage.trim() === "") {
newErrors.customMessage = "Vui lòng nhập trạng thái";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleConfirmSos = async () => {
if (validateForm()) {
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
setShowConfirmSosDialog(false);
// Reset form
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
await sendSosMessage(messageToSend);
}
};
const handleClickButton = async (isActive: boolean) => {
if (isActive) {
console.log("Active");
const resp = await queryDeleteSos();
if (resp.status === 200) {
await getSosData();
}
} else {
console.log("No Active");
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);
showToastError("Không thể gửi tín hiệu SOS", "Lỗi");
}
};
return (
<>
<Button
className="shadow-md rounded-full"
size="lg"
action="negative"
onPress={() => handleClickButton(sosData?.active || false)}
>
<MaterialIcons name="warning" size={15} color="white" />
<ButtonText className="text-center">
{sosData?.active ? "Đang trong trạng thái khẩn cấp" : "Khẩn cấp"}
</ButtonText>
{/* <ButtonSpinner /> */}
{/* <ButtonIcon /> */}
</Button>
<Modal
isOpen={showConfirmSosDialog}
onClose={() => {
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
}}
>
<ModalBackdrop />
<ModalContent>
<ModalHeader className="flex-col gap-0.5 items-center">
<Text
style={{ fontSize: 18, fontWeight: "bold", textAlign: "center" }}
>
Thông báo khẩn cấp
</Text>
</ModalHeader>
<ModalBody className="mb-4">
<ScrollView style={{ maxHeight: 400 }}>
{/* Dropdown Nội dung SOS */}
<View style={styles.formGroup}>
<Text style={styles.label}>Nội dung:</Text>
<TouchableOpacity
style={[
styles.dropdownButton,
errors.sosMessage ? styles.errorBorder : {},
]}
onPress={() => setShowDropdown(!showDropdown)}
>
<Text
style={[
styles.dropdownButtonText,
!selectedSosMessage && styles.placeholderText,
]}
>
{selectedSosMessage !== null
? sosOptions.find((opt) => opt.ma === selectedSosMessage)
?.moTa || "Chọn lý do"
: "Chọn lý do"}
</Text>
<MaterialIcons
name={showDropdown ? "expand-less" : "expand-more"}
size={20}
color="#666"
/>
</TouchableOpacity>
{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}>Nhập trạng thái</Text>
<TextInput
style={[
styles.input,
errors.customMessage ? styles.errorInput : {},
]}
placeholder="Mô tả trạng thái khẩn cấp"
placeholderTextColor="#999"
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>
)}
</ScrollView>
</ModalBody>
<ModalFooter className="flex-row items-start gap-2">
<Button
onPress={handleConfirmSos}
// className="w-1/3"
action="negative"
>
<ButtonText>Xác nhận</ButtonText>
</Button>
<Button
onPress={() => {
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
}}
// className="w-1/3"
action="secondary"
>
<ButtonText>Hủy</ButtonText>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Dropdown Modal - Nổi lên */}
{showDropdown && showConfirmSosDialog && (
<Modal isOpen={showDropdown} onClose={() => setShowDropdown(false)}>
<TouchableOpacity
style={styles.dropdownOverlay}
activeOpacity={1}
onPress={() => setShowDropdown(false)}
>
<View style={styles.dropdownModalContainer}>
<FlatList
data={sosOptions}
keyExtractor={(item) => item.ma.toString()}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.dropdownModalItem}
onPress={() => {
setSelectedSosMessage(item.ma);
setShowDropdown(false);
// Clear custom message nếu chọn khác lý do
if (item.ma !== 999) {
setCustomMessage("");
}
}}
>
<Text
style={[
styles.dropdownModalItemText,
selectedSosMessage === item.ma &&
styles.selectedItemText,
]}
>
{item.moTa}
</Text>
</TouchableOpacity>
)}
/>
</View>
</TouchableOpacity>
</Modal>
)}
</>
);
};
const styles = StyleSheet.create({
formGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
marginBottom: 8,
color: "#333",
},
dropdownButton: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: "#fff",
},
errorBorder: {
borderColor: "#ff4444",
},
dropdownButtonText: {
fontSize: 14,
color: "#333",
flex: 1,
},
placeholderText: {
color: "#999",
},
dropdownList: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
marginTop: 4,
backgroundColor: "#fff",
overflow: "hidden",
},
dropdownItem: {
paddingHorizontal: 12,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
dropdownItemText: {
fontSize: 14,
color: "#333",
},
dropdownOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
dropdownModalContainer: {
backgroundColor: "#fff",
borderRadius: 12,
maxHeight: 400,
minWidth: 280,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 10,
},
dropdownModalItem: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
dropdownModalItemText: {
fontSize: 14,
color: "#333",
},
selectedItemText: {
fontWeight: "600",
color: "#1054C9",
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 14,
color: "#333",
textAlignVertical: "top",
},
errorInput: {
borderColor: "#ff4444",
},
errorText: {
color: "#ff4444",
fontSize: 12,
marginTop: 4,
},
});
export default SosButton;