thêm chức năng Sos và thêm glustack-ui
This commit is contained in:
17
components/map/Description.tsx
Normal file
17
components/map/Description.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
components/map/GPSInfoPanel.tsx
Normal file
113
components/map/GPSInfoPanel.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
385
components/map/SosButton.tsx
Normal file
385
components/map/SosButton.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user