hiển thị thuyền thông tin tàu
This commit is contained in:
253
components/DraggablePanel.tsx
Normal file
253
components/DraggablePanel.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface DraggablePanelProps {
|
||||
minHeightPct?: number;
|
||||
maxHeightPct?: number;
|
||||
initialState?: "min" | "max";
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DraggablePanel({
|
||||
minHeightPct = 0.1,
|
||||
maxHeightPct = 0.6,
|
||||
initialState = "min",
|
||||
onExpandedChange,
|
||||
children,
|
||||
}: DraggablePanelProps) {
|
||||
const { height: screenHeight } = useWindowDimensions();
|
||||
|
||||
// Thêm chiều cao của bottom tab bar vào tính toán
|
||||
const bottomOffset = 80; // 50 là chiều cao mặc định của tab bar
|
||||
|
||||
const minHeight = screenHeight * minHeightPct;
|
||||
const maxHeight = screenHeight * maxHeightPct;
|
||||
|
||||
// State để quản lý icon
|
||||
const [iconName, setIconName] = useState<"chevron-down" | "chevron-up">(
|
||||
initialState === "max" ? "chevron-down" : "chevron-up"
|
||||
);
|
||||
|
||||
// Sử dụng translateY để điều khiển vị trí
|
||||
const translateY = useSharedValue(
|
||||
initialState === "min"
|
||||
? screenHeight - minHeight - bottomOffset
|
||||
: screenHeight - maxHeight - bottomOffset
|
||||
);
|
||||
const isExpanded = useSharedValue(initialState === "max");
|
||||
|
||||
// Update khi screen height thay đổi (xoay màn hình)
|
||||
useEffect(() => {
|
||||
const currentHeight = isExpanded.value ? maxHeight : minHeight;
|
||||
translateY.value = screenHeight - currentHeight - bottomOffset;
|
||||
}, [screenHeight, minHeight, maxHeight, bottomOffset]);
|
||||
|
||||
const notifyExpandedChange = (expanded: boolean) => {
|
||||
if (onExpandedChange) {
|
||||
onExpandedChange(expanded);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePanel = () => {
|
||||
const newExpanded = !isExpanded.value;
|
||||
isExpanded.value = newExpanded;
|
||||
|
||||
const targetY = newExpanded
|
||||
? screenHeight - maxHeight - bottomOffset
|
||||
: screenHeight - minHeight - bottomOffset;
|
||||
|
||||
translateY.value = withTiming(targetY, {
|
||||
duration: 500,
|
||||
});
|
||||
|
||||
notifyExpandedChange(newExpanded);
|
||||
};
|
||||
|
||||
const startY = useSharedValue(0);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
startY.value = translateY.value;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Cập nhật translateY theo gesture
|
||||
const newY = startY.value + event.translationY;
|
||||
|
||||
// Clamp giá trị trong khoảng [screenHeight - maxHeight - bottomOffset, screenHeight - minHeight - bottomOffset]
|
||||
const minY = screenHeight - maxHeight - bottomOffset;
|
||||
const maxY = screenHeight - minHeight - bottomOffset;
|
||||
|
||||
if (newY >= minY && newY <= maxY) {
|
||||
translateY.value = newY;
|
||||
} else if (newY < minY) {
|
||||
translateY.value = minY;
|
||||
} else if (newY > maxY) {
|
||||
translateY.value = maxY;
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
// Tính toán vị trí để snap
|
||||
const currentHeight = screenHeight - translateY.value - bottomOffset;
|
||||
const midHeight = (minHeight + maxHeight) / 2;
|
||||
|
||||
// Kiểm tra velocity để quyết định snap
|
||||
const snapToMax = event.velocityY < -100 || currentHeight > midHeight;
|
||||
|
||||
const targetY = snapToMax
|
||||
? screenHeight - maxHeight - bottomOffset + 40
|
||||
: screenHeight - minHeight - bottomOffset;
|
||||
|
||||
translateY.value = withSpring(
|
||||
targetY,
|
||||
{
|
||||
damping: 20,
|
||||
stiffness: 200,
|
||||
},
|
||||
() => {
|
||||
"worklet";
|
||||
isExpanded.value = snapToMax;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateY: translateY.value }],
|
||||
};
|
||||
});
|
||||
|
||||
// Sử dụng useAnimatedReaction để cập nhật icon dựa trên chiều cao
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
const currentHeight = screenHeight - translateY.value - bottomOffset;
|
||||
return currentHeight > minHeight + 10;
|
||||
},
|
||||
(isCurrentlyExpanded) => {
|
||||
const newIcon = isCurrentlyExpanded ? "chevron-down" : "chevron-up";
|
||||
runOnJS(setIconName)(newIcon);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.panelContainer, animatedStyle]}>
|
||||
<View style={[styles.panel, { height: maxHeight }]}>
|
||||
{/* Header với drag handle và nút toggle */}
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Pressable onPress={togglePanel} style={styles.header}>
|
||||
<View style={styles.dragHandle} />
|
||||
<TouchableOpacity
|
||||
onPress={togglePanel}
|
||||
style={styles.toggleButton}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name={iconName} size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Nội dung */}
|
||||
<View style={styles.content}>
|
||||
{children || (
|
||||
<View style={styles.placeholderContent}>
|
||||
<Text style={styles.placeholderText}>Draggable Panel</Text>
|
||||
<Text style={styles.placeholderSubtext}>
|
||||
Click hoặc kéo để mở rộng panel này
|
||||
</Text>
|
||||
<Text style={styles.placeholderSubtext}>
|
||||
Min: {(minHeightPct * 100).toFixed(0)}% | Max:{" "}
|
||||
{(maxHeightPct * 100).toFixed(0)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
panelContainer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "100%",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
panel: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "white",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
dragHandle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: "#D1D5DB",
|
||||
borderRadius: 2,
|
||||
marginBottom: 8,
|
||||
},
|
||||
toggleButton: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: 1,
|
||||
padding: 4,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
// paddingBottom: 16,
|
||||
},
|
||||
placeholderContent: {
|
||||
alignItems: "center",
|
||||
paddingTop: 20,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#333",
|
||||
marginBottom: 8,
|
||||
},
|
||||
placeholderSubtext: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
522
components/ShipSearchForm.tsx
Normal file
522
components/ShipSearchForm.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { Colors } from "@/config";
|
||||
import { queryShipGroups } from "@/controller/DeviceController";
|
||||
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
|
||||
import { useShipTypes } from "@/state/use-ship-types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
Animated,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import Select from "./Select";
|
||||
import Slider from "./Slider";
|
||||
|
||||
interface ShipSearchFormProps {
|
||||
initialValues?: Partial<SearchShipResponse>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (data: SearchShipResponse) => void;
|
||||
}
|
||||
|
||||
export interface SearchShipResponse {
|
||||
ship_name: string;
|
||||
ship_length: [number, number];
|
||||
reg_number: string;
|
||||
ship_power: [number, number];
|
||||
ship_type: string | number;
|
||||
alarm_list: string;
|
||||
ship_group_id: string;
|
||||
group_id: string;
|
||||
}
|
||||
|
||||
const ShipSearchForm = (props: ShipSearchFormProps) => {
|
||||
const { colors, colorScheme } = useTheme();
|
||||
const styles = useMemo(
|
||||
() => createStyles(colors, colorScheme),
|
||||
[colors, colorScheme]
|
||||
);
|
||||
const { shipTypes, getShipTypes } = useShipTypes();
|
||||
const [groupShips, setGroupShips] = useState<Model.ShipGroup[]>([]);
|
||||
const [slideAnim] = useState(new Animated.Value(0));
|
||||
|
||||
const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({
|
||||
defaultValues: {
|
||||
ship_name: props.initialValues?.ship_name || "",
|
||||
reg_number: props.initialValues?.reg_number || "",
|
||||
ship_length: [0, 100],
|
||||
ship_power: [0, 100000],
|
||||
ship_type: props.initialValues?.ship_type || "",
|
||||
alarm_list: props.initialValues?.alarm_list || "",
|
||||
ship_group_id: props.initialValues?.ship_group_id || "",
|
||||
group_id: props.initialValues?.group_id || "",
|
||||
},
|
||||
});
|
||||
|
||||
const shipLengthValue = watch("ship_length");
|
||||
const shipPowerValue = watch("ship_power");
|
||||
|
||||
useEffect(() => {
|
||||
if (shipTypes.length === 0) {
|
||||
getShipTypes();
|
||||
}
|
||||
}, [shipTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
getShipGroups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.isOpen) {
|
||||
Animated.spring(slideAnim, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
tension: 50,
|
||||
friction: 8,
|
||||
}).start();
|
||||
} else {
|
||||
slideAnim.setValue(0);
|
||||
}
|
||||
}, [props.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.initialValues) {
|
||||
reset({
|
||||
ship_name: props.initialValues.ship_name || "",
|
||||
reg_number: props.initialValues.reg_number || "",
|
||||
ship_length: [0, 100],
|
||||
ship_power: [0, 100000],
|
||||
ship_type: props.initialValues.ship_type || "",
|
||||
alarm_list: props.initialValues.alarm_list || "",
|
||||
ship_group_id: props.initialValues.ship_group_id || "",
|
||||
group_id: props.initialValues.group_id || "",
|
||||
});
|
||||
}
|
||||
}, [props.initialValues]);
|
||||
|
||||
const getShipGroups = async () => {
|
||||
try {
|
||||
const response = await queryShipGroups();
|
||||
if (response && response.data) {
|
||||
setGroupShips(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching ship groups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const alarmListLabel = [
|
||||
{
|
||||
label: "Tiếp cận vùng hạn chế",
|
||||
value: "50:10",
|
||||
},
|
||||
{
|
||||
label: "Đã ra (vào) vùng hạn chế)",
|
||||
value: "50:11",
|
||||
},
|
||||
{
|
||||
label: "Đang đánh bắt trong vùng hạn chế",
|
||||
value: "50:12",
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmitForm = (data: SearchShipResponse) => {
|
||||
props.onSubmit?.(data);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({
|
||||
ship_name: "",
|
||||
reg_number: "",
|
||||
ship_length: [0, 100],
|
||||
ship_power: [0, 100000],
|
||||
ship_type: "",
|
||||
alarm_list: "",
|
||||
ship_group_id: "",
|
||||
group_id: "",
|
||||
});
|
||||
};
|
||||
|
||||
const translateY = slideAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [600, 0],
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={props.isOpen}
|
||||
onRequestClose={props.onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable style={styles.backdrop} onPress={props.onClose}>
|
||||
<Animated.View
|
||||
style={[styles.modalContent, { transform: [{ translateY }] }]}
|
||||
onStartShouldSetResponder={() => true}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.dragIndicator} />
|
||||
<Text style={[styles.headerTitle, { color: colors.text }]}>
|
||||
Tìm kiếm tàu
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={props.onClose}
|
||||
style={styles.closeButton}
|
||||
>
|
||||
<Text style={[styles.closeButtonText, { color: colors.text }]}>
|
||||
✕
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Form Content */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.formSection}>
|
||||
{/* Tên tàu */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>
|
||||
Tên tàu
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_name"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextInput
|
||||
placeholder="Hoàng Sa 001"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Số đăng ký */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>
|
||||
Số đăng ký
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="reg_number"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextInput
|
||||
placeholder="VN-00001"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Chiều dài */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>
|
||||
Chiều dài ({shipLengthValue[0]}m - {shipLengthValue[1]}m)
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_length"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.sliderContainer}>
|
||||
<Slider
|
||||
range={true}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={value}
|
||||
marks={{
|
||||
0: "0m",
|
||||
50: "50m",
|
||||
100: "100m",
|
||||
}}
|
||||
onValueChange={(val) =>
|
||||
onChange(val as [number, number])
|
||||
}
|
||||
activeColor={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Công suất */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>
|
||||
Công suất ({shipPowerValue[0]}kW - {shipPowerValue[1]}kW)
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_power"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.sliderContainer}>
|
||||
<Slider
|
||||
range={true}
|
||||
min={0}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={value}
|
||||
marks={{
|
||||
0: "0kW",
|
||||
50000: "50,000kW",
|
||||
100000: "100,000kW",
|
||||
}}
|
||||
onValueChange={(val) =>
|
||||
onChange(val as [number, number])
|
||||
}
|
||||
activeColor={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Loại tàu */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>
|
||||
Loại tàu
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_type"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
options={shipTypes.map((type) => ({
|
||||
label: type.name || "",
|
||||
value: type.id || 0,
|
||||
}))}
|
||||
placeholder="Chọn loại tàu"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Cảnh báo */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>
|
||||
Cảnh báo
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="alarm_list"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
options={alarmListLabel.map((type) => ({
|
||||
label: type.label || "",
|
||||
value: type.value || "",
|
||||
}))}
|
||||
placeholder="Chọn loại cảnh báo"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Đội tàu */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>
|
||||
Đội tàu
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_group_id"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
options={groupShips.map((group) => ({
|
||||
label: group.name || "",
|
||||
value: group.id || "",
|
||||
}))}
|
||||
placeholder="Chọn đội tàu"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View
|
||||
style={[styles.actionButtons, { borderTopColor: colors.border }]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.resetButton, { borderColor: colors.border }]}
|
||||
onPress={onReset}
|
||||
>
|
||||
<Text style={[styles.resetButtonText, { color: colors.text }]}>
|
||||
Đặt lại
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.submitButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
onPress={handleSubmit(onSubmitForm)}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>Tìm kiếm</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
|
||||
StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContent: {
|
||||
height: "85%",
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -4,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
position: "relative",
|
||||
},
|
||||
dragIndicator: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: colors.border,
|
||||
borderRadius: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 16,
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 20,
|
||||
fontWeight: "300",
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
formSection: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
},
|
||||
fieldGroup: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
marginBottom: 10,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 15,
|
||||
},
|
||||
sliderContainer: {
|
||||
paddingHorizontal: 4,
|
||||
paddingTop: 8,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
gap: 12,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
resetButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
resetButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
submitButtonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
export default ShipSearchForm;
|
||||
346
components/Slider.tsx
Normal file
346
components/Slider.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LayoutChangeEvent, StyleSheet, Text, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface SliderProps {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
range?: boolean;
|
||||
value?: number | [number, number];
|
||||
onValueChange?: (value: any) => void;
|
||||
onSlidingComplete?: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
trackHeight?: number;
|
||||
thumbSize?: number;
|
||||
activeColor?: string;
|
||||
inactiveColor?: string;
|
||||
marks?: Record<number, string>;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export default function Slider({
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
range = false,
|
||||
value = 0,
|
||||
onValueChange,
|
||||
onSlidingComplete,
|
||||
disabled = false,
|
||||
trackHeight = 4,
|
||||
thumbSize = 20,
|
||||
activeColor = "#1677ff", // Ant Design blue
|
||||
inactiveColor = "#e5e7eb", // Gray-200
|
||||
marks,
|
||||
style,
|
||||
}: SliderProps) {
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
// Shared values for positions
|
||||
const translateX1 = useSharedValue(0); // Left thumb (or 0 for single)
|
||||
const translateX2 = useSharedValue(0); // Right thumb (or value for single)
|
||||
|
||||
const isDragging1 = useSharedValue(false);
|
||||
const isDragging2 = useSharedValue(false);
|
||||
const scale1 = useSharedValue(1);
|
||||
const scale2 = useSharedValue(1);
|
||||
const context1 = useSharedValue(0);
|
||||
const context2 = useSharedValue(0);
|
||||
|
||||
// Calculate position from value
|
||||
const getPositionFromValue = (val: number) => {
|
||||
"worklet";
|
||||
if (width === 0) return 0;
|
||||
const clampedVal = Math.min(Math.max(val, min), max);
|
||||
return ((clampedVal - min) / (max - min)) * width;
|
||||
};
|
||||
|
||||
// Calculate value from position
|
||||
const getValueFromPosition = (pos: number) => {
|
||||
"worklet";
|
||||
if (width === 0) return min;
|
||||
const percentage = Math.min(Math.max(pos, 0), width) / width;
|
||||
const rawValue = min + percentage * (max - min);
|
||||
|
||||
// Snap to step
|
||||
const steppedValue = Math.round(rawValue / step) * step;
|
||||
return Math.min(Math.max(steppedValue, min), max);
|
||||
};
|
||||
|
||||
// Update positions when props change
|
||||
useEffect(() => {
|
||||
if (width === 0) return;
|
||||
|
||||
if (range) {
|
||||
const vals = Array.isArray(value) ? value : [min, value as number];
|
||||
const v1 = Math.min(vals[0], vals[1]);
|
||||
const v2 = Math.max(vals[0], vals[1]);
|
||||
|
||||
if (!isDragging1.value) {
|
||||
translateX1.value = withTiming(getPositionFromValue(v1), {
|
||||
duration: 200,
|
||||
});
|
||||
}
|
||||
if (!isDragging2.value) {
|
||||
translateX2.value = withTiming(getPositionFromValue(v2), {
|
||||
duration: 200,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const val = typeof value === "number" ? value : value[0];
|
||||
if (!isDragging2.value) {
|
||||
translateX2.value = withTiming(getPositionFromValue(val), {
|
||||
duration: 200,
|
||||
});
|
||||
}
|
||||
translateX1.value = 0; // Always 0 for single slider track start
|
||||
}
|
||||
}, [value, width, min, max, range]);
|
||||
|
||||
// Thumb 1 Gesture (Only for range)
|
||||
const thumb1Gesture = Gesture.Pan()
|
||||
.enabled(!disabled && range)
|
||||
.onStart(() => {
|
||||
context1.value = translateX1.value;
|
||||
isDragging1.value = true;
|
||||
scale1.value = withSpring(1.2);
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (width === 0) return;
|
||||
let newPos = context1.value + event.translationX;
|
||||
// Constrain: 0 <= newPos <= translateX2
|
||||
const maxPos = translateX2.value;
|
||||
newPos = Math.min(Math.max(newPos, 0), maxPos);
|
||||
translateX1.value = newPos;
|
||||
|
||||
if (onValueChange) {
|
||||
const v1 = getValueFromPosition(newPos);
|
||||
const v2 = getValueFromPosition(translateX2.value);
|
||||
runOnJS(onValueChange)([v1, v2]);
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
isDragging1.value = false;
|
||||
scale1.value = withSpring(1);
|
||||
if (onSlidingComplete) {
|
||||
const v1 = getValueFromPosition(translateX1.value);
|
||||
const v2 = getValueFromPosition(translateX2.value);
|
||||
runOnJS(onSlidingComplete)([v1, v2]);
|
||||
}
|
||||
});
|
||||
|
||||
// Thumb 2 Gesture (Main thumb for single, Right thumb for range)
|
||||
const thumb2Gesture = Gesture.Pan()
|
||||
.enabled(!disabled)
|
||||
.onStart(() => {
|
||||
context2.value = translateX2.value;
|
||||
isDragging2.value = true;
|
||||
scale2.value = withSpring(1.2);
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (width === 0) return;
|
||||
let newPos = context2.value + event.translationX;
|
||||
// Constrain: translateX1 <= newPos <= width
|
||||
const minPos = range ? translateX1.value : 0;
|
||||
newPos = Math.min(Math.max(newPos, minPos), width);
|
||||
translateX2.value = newPos;
|
||||
|
||||
if (onValueChange) {
|
||||
const v2 = getValueFromPosition(newPos);
|
||||
if (range) {
|
||||
const v1 = getValueFromPosition(translateX1.value);
|
||||
runOnJS(onValueChange)([v1, v2]);
|
||||
} else {
|
||||
runOnJS(onValueChange)(v2);
|
||||
}
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
isDragging2.value = false;
|
||||
scale2.value = withSpring(1);
|
||||
if (onSlidingComplete) {
|
||||
const v2 = getValueFromPosition(translateX2.value);
|
||||
if (range) {
|
||||
const v1 = getValueFromPosition(translateX1.value);
|
||||
runOnJS(onSlidingComplete)([v1, v2]);
|
||||
} else {
|
||||
runOnJS(onSlidingComplete)(v2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const trackStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
left: range ? translateX1.value : 0,
|
||||
width: range ? translateX2.value - translateX1.value : translateX2.value,
|
||||
};
|
||||
});
|
||||
|
||||
const thumb1Style = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: translateX1.value - thumbSize / 2 },
|
||||
{ scale: scale1.value },
|
||||
],
|
||||
zIndex: isDragging1.value ? 10 : 1,
|
||||
};
|
||||
});
|
||||
|
||||
const thumb2Style = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: translateX2.value - thumbSize / 2 },
|
||||
{ scale: scale2.value },
|
||||
],
|
||||
zIndex: isDragging2.value ? 10 : 1,
|
||||
};
|
||||
});
|
||||
|
||||
const onLayout = (event: LayoutChangeEvent) => {
|
||||
setWidth(event.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
const containerHeight = Math.max(trackHeight, thumbSize) + (marks ? 30 : 0);
|
||||
const marksTop = Math.max(trackHeight, thumbSize) + 10;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style, { height: containerHeight }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.track,
|
||||
{
|
||||
height: trackHeight,
|
||||
backgroundColor: inactiveColor,
|
||||
borderRadius: trackHeight / 2,
|
||||
},
|
||||
]}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.activeTrack,
|
||||
trackStyle,
|
||||
{
|
||||
height: trackHeight,
|
||||
backgroundColor: activeColor,
|
||||
borderRadius: trackHeight / 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Thumb 1 - Only render if range is true */}
|
||||
{range && (
|
||||
<GestureDetector gesture={thumb1Gesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.thumb,
|
||||
thumb1Style,
|
||||
{
|
||||
width: thumbSize,
|
||||
height: thumbSize,
|
||||
borderRadius: thumbSize / 2,
|
||||
backgroundColor: "white",
|
||||
borderColor: activeColor,
|
||||
borderWidth: 2,
|
||||
},
|
||||
disabled && styles.disabledThumb,
|
||||
]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
)}
|
||||
|
||||
{/* Thumb 2 - Always render */}
|
||||
<GestureDetector gesture={thumb2Gesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.thumb,
|
||||
thumb2Style,
|
||||
{
|
||||
width: thumbSize,
|
||||
height: thumbSize,
|
||||
borderRadius: thumbSize / 2,
|
||||
backgroundColor: "white",
|
||||
borderColor: activeColor,
|
||||
borderWidth: 2,
|
||||
},
|
||||
disabled && styles.disabledThumb,
|
||||
]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Marks */}
|
||||
{marks && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: marksTop,
|
||||
width: "100%",
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
{Object.entries(marks).map(([key, label]) => {
|
||||
const val = Number(key);
|
||||
const pos = getPositionFromValue(val);
|
||||
return (
|
||||
<Text
|
||||
key={key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: pos - 10,
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
width: "auto",
|
||||
marginTop: 5,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
},
|
||||
track: {
|
||||
width: "100%",
|
||||
position: "absolute",
|
||||
overflow: "hidden",
|
||||
},
|
||||
activeTrack: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
thumb: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
disabledThumb: {
|
||||
borderColor: "#ccc",
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
import { View } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
|
||||
interface DescriptionProps {
|
||||
title?: string;
|
||||
@@ -13,12 +13,14 @@ export const Description = ({
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View className="flex-row gap-2 ">
|
||||
<ThemedText
|
||||
style={{ color: colors.textSecondary, fontSize: 16 }}
|
||||
>
|
||||
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
|
||||
{title}:
|
||||
</ThemedText>
|
||||
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
|
||||
{description || "-"}
|
||||
</ThemedText>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
141
components/map/ShipInfo.tsx
Normal file
141
components/map/ShipInfo.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { STATUS_DANGEROUS, STATUS_NORMAL, STATUS_WARNING } from "@/constants";
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { formatRelativeTime } from "@/services/time_service";
|
||||
import { convertToDMS, kmhToKnot } from "@/utils/geom";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { ThemedText } from "../themed-text";
|
||||
|
||||
interface ShipInfoProps {
|
||||
thingMetadata?: Model.ThingMetadata;
|
||||
}
|
||||
|
||||
const ShipInfo = ({ thingMetadata }: ShipInfoProps) => {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useAppTheme();
|
||||
// Định nghĩa màu sắc theo trạng thái
|
||||
const statusConfig = {
|
||||
normal: {
|
||||
dotColor: "bg-green-500",
|
||||
badgeColor: "bg-green-100",
|
||||
badgeTextColor: "text-green-700",
|
||||
badgeText: "Bình thường",
|
||||
},
|
||||
warning: {
|
||||
dotColor: "bg-yellow-500",
|
||||
badgeColor: "bg-yellow-100",
|
||||
badgeTextColor: "text-yellow-700",
|
||||
badgeText: "Cảnh báo",
|
||||
},
|
||||
danger: {
|
||||
dotColor: "bg-red-500",
|
||||
badgeColor: "bg-red-100",
|
||||
badgeTextColor: "text-red-700",
|
||||
badgeText: "Nguy hiểm",
|
||||
},
|
||||
};
|
||||
const getThingStatus = () => {
|
||||
switch (thingMetadata?.state_level) {
|
||||
case STATUS_NORMAL:
|
||||
return "normal";
|
||||
case STATUS_WARNING:
|
||||
return "warning";
|
||||
case STATUS_DANGEROUS:
|
||||
return "danger";
|
||||
default:
|
||||
return "normal";
|
||||
}
|
||||
};
|
||||
|
||||
const gpsData: Model.GPSResponse = JSON.parse(thingMetadata?.gps || "{}");
|
||||
|
||||
const currentStatus = statusConfig[getThingStatus()];
|
||||
|
||||
// Format tọa độ
|
||||
const formatCoordinate = (lat: number, lon: number) => {
|
||||
const latDir = lat >= 0 ? "N" : "S";
|
||||
const lonDir = lon >= 0 ? "E" : "W";
|
||||
return `${Math.abs(lat).toFixed(4)}°${latDir}, ${Math.abs(lon).toFixed(
|
||||
4
|
||||
)}°${lonDir}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="px-4 py-3">
|
||||
{/* Header: Tên tàu và trạng thái */}
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="flex-row items-center gap-2">
|
||||
{/* Status dot */}
|
||||
<View className={`h-3 w-3 rounded-full ${currentStatus.dotColor}`} />
|
||||
|
||||
{/* Tên tàu */}
|
||||
<Text className="text-lg font-semibold text-gray-900">
|
||||
{thingMetadata?.ship_name}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Badge trạng thái */}
|
||||
<View className={`px-3 py-1 rounded-full ${currentStatus.badgeColor}`}>
|
||||
<Text
|
||||
className={`text-md font-medium ${currentStatus.badgeTextColor}`}
|
||||
>
|
||||
{currentStatus.badgeText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Mã tàu */}
|
||||
{/* <Text className="text-base text-gray-600 mb-2">{shipCode}</Text> */}
|
||||
<View className="flex-row items-center justify-between gap-2 mb-3">
|
||||
<View className="flex-row items-center gap-2 w-2/3">
|
||||
<Ionicons name="speedometer-outline" size={16} color="#6B7280" />
|
||||
<Text className="text-md text-gray-600">
|
||||
{kmhToKnot(gpsData.s || 0)} {t("home.speed_units")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-start gap-2 w-1/3 ">
|
||||
<Ionicons name="compass-outline" size={16} color="#6B7280" />
|
||||
<Text className="text-md text-gray-600">{gpsData.h}°</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Tọa độ */}
|
||||
<View className="flex-row items-center justify-between gap-2 mb-2">
|
||||
<View className="flex-row items-center gap-2 w-2/3">
|
||||
<Ionicons name="location-outline" size={16} color="#6B7280" />
|
||||
<Text className="text-md text-gray-600">
|
||||
{convertToDMS(gpsData.lat || 0, true)},
|
||||
{convertToDMS(gpsData.lon || 0, false)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className=" flex-row items-start gap-2 w-1/3 ">
|
||||
<Ionicons name="time-outline" size={16} color="#6B7280" />
|
||||
<Text className="text-md text-gray-600">
|
||||
{formatRelativeTime(gpsData.t)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* <View className="flex">
|
||||
<Description title="Trạng thái" description={thingMetadata?.state} />
|
||||
</View> */}
|
||||
{thingMetadata?.state !== "" && (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<ThemedText style={{ color: colors.textSecondary, fontSize: 15 }}>
|
||||
Trạng thái:
|
||||
</ThemedText>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ThemedText style={{ color: colors.text, fontSize: 15 }}>
|
||||
{thingMetadata?.state || "-"}
|
||||
</ThemedText>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShipInfo;
|
||||
165
components/map/TagState.tsx
Normal file
165
components/map/TagState.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
import { ThemedText } from "../themed-text";
|
||||
|
||||
export type TagStateCallbackPayload = {
|
||||
isNormal: boolean;
|
||||
isWarning: boolean;
|
||||
isDangerous: boolean;
|
||||
isSos: boolean;
|
||||
isDisconected: boolean; // giữ đúng theo yêu cầu (1 'n')
|
||||
};
|
||||
|
||||
export type TagStateProps = {
|
||||
normalCount?: number;
|
||||
warningCount?: number;
|
||||
dangerousCount?: number;
|
||||
sosCount?: number;
|
||||
disconnectedCount?: number;
|
||||
onTagPress?: (selection: TagStateCallbackPayload) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function TagState({
|
||||
normalCount = 0,
|
||||
warningCount = 0,
|
||||
dangerousCount = 0,
|
||||
sosCount = 0,
|
||||
disconnectedCount = 0,
|
||||
onTagPress,
|
||||
className = "",
|
||||
}: TagStateProps) {
|
||||
// Quản lý trạng thái các tag ở cấp cha để trả về tổng hợp
|
||||
const [activeStates, setActiveStates] = useState({
|
||||
normal: false,
|
||||
warning: false,
|
||||
dangerous: false,
|
||||
sos: false,
|
||||
disconnected: false,
|
||||
});
|
||||
|
||||
const toggleState = (state: keyof typeof activeStates) => {
|
||||
setActiveStates((prev) => {
|
||||
const next = { ...prev, [state]: !prev[state] };
|
||||
// Gọi callback với object tổng hợp sau khi cập nhật
|
||||
onTagPress?.({
|
||||
isNormal: next.normal,
|
||||
isWarning: next.warning,
|
||||
isDangerous: next.dangerous,
|
||||
isSos: next.sos,
|
||||
isDisconected: next.disconnected,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderTag = (
|
||||
state: "normal" | "warning" | "dangerous" | "sos" | "disconnected",
|
||||
count: number,
|
||||
label: string,
|
||||
colors: {
|
||||
defaultBg: string;
|
||||
defaultBorder: string;
|
||||
defaultText: string;
|
||||
activeBg: string;
|
||||
activeBorder: string;
|
||||
activeText: string;
|
||||
}
|
||||
) => {
|
||||
if (count === 0) return null;
|
||||
const pressed = activeStates[state];
|
||||
return (
|
||||
<Pressable
|
||||
key={state}
|
||||
onPress={() => toggleState(state)}
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
backgroundColor: pressed ? colors.activeBg : colors.defaultBg,
|
||||
borderColor: pressed ? colors.activeBorder : colors.defaultBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.tagText,
|
||||
{
|
||||
color: pressed ? colors.activeText : colors.defaultText,
|
||||
},
|
||||
]}
|
||||
type="defaultSemiBold"
|
||||
>
|
||||
{label}: {count}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className={className} style={styles.container}>
|
||||
{renderTag("normal", normalCount, "Bình thường", {
|
||||
defaultBg: "#FFFFFF",
|
||||
defaultBorder: "#22C55E",
|
||||
defaultText: "#22C55E",
|
||||
activeBg: "#22C55E",
|
||||
activeBorder: "#22C55E",
|
||||
activeText: "#FFFFFF",
|
||||
})}
|
||||
{renderTag("warning", warningCount, "Cảnh báo", {
|
||||
defaultBg: "#FFFFFF",
|
||||
defaultBorder: "#EAB308",
|
||||
defaultText: "#EAB308",
|
||||
activeBg: "#EAB308",
|
||||
activeBorder: "#EAB308",
|
||||
activeText: "#FFFFFF",
|
||||
})}
|
||||
{renderTag("dangerous", dangerousCount, "Nguy hiểm", {
|
||||
defaultBg: "#FFFFFF",
|
||||
defaultBorder: "#F97316",
|
||||
defaultText: "#F97316",
|
||||
activeBg: "#F97316",
|
||||
activeBorder: "#F97316",
|
||||
activeText: "#FFFFFF",
|
||||
})}
|
||||
{renderTag("sos", sosCount, "SOS", {
|
||||
defaultBg: "#FFFFFF",
|
||||
defaultBorder: "#EF4444",
|
||||
defaultText: "#EF4444",
|
||||
activeBg: "#EF4444",
|
||||
activeBorder: "#EF4444",
|
||||
activeText: "#FFFFFF",
|
||||
})}
|
||||
{renderTag("disconnected", disconnectedCount, "Mất kết nối", {
|
||||
defaultBg: "#FFFFFF",
|
||||
defaultBorder: "#6B7280",
|
||||
defaultText: "#6B7280",
|
||||
activeBg: "#6B7280",
|
||||
activeBorder: "#6B7280",
|
||||
activeText: "#FFFFFF",
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
padding: 5,
|
||||
},
|
||||
tag: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
minWidth: 100,
|
||||
alignItems: "center",
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user