From 4d60ce279eca4f717a63116a9db51475018ed847 Mon Sep 17 00:00:00 2001 From: Tran Anh Tuan Date: Thu, 4 Dec 2025 15:02:53 +0700 Subject: [PATCH] =?UTF-8?q?th=C3=AAm=20FormSearch=20trong=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 235 +++++++++++++++++++++++++++------- components/DraggablePanel.tsx | 29 ++--- components/Select.tsx | 84 ++++++++---- components/ShipSearchForm.tsx | 18 ++- components/Slider.tsx | 3 +- components/map/TagState.tsx | 2 +- 6 files changed, 275 insertions(+), 96 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4315f09..9b030d8 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,14 +1,19 @@ import DraggablePanel from "@/components/DraggablePanel"; +import IconButton from "@/components/IconButton"; import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel"; import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel"; import ShipInfo from "@/components/map/ShipInfo"; import { TagState, TagStateCallbackPayload } from "@/components/map/TagState"; +import ShipSearchForm, { + SearchShipResponse, +} from "@/components/ShipSearchForm"; import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants"; import { usePlatform } from "@/hooks/use-platform"; import { useThemeContext } from "@/hooks/use-theme-context"; import { searchThingEventBus } from "@/services/device_events"; import { getShipIcon } from "@/services/map_service"; import eventBus from "@/utils/eventBus"; +import { AntDesign } from "@expo/vector-icons"; import { useEffect, useRef, useState } from "react"; import { Animated, @@ -36,15 +41,34 @@ export default function HomeScreen() { const [polygonCoordinates, setPolygonCoordinates] = useState< PolygonWithLabelProps[] >([]); + const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false); + const [isPanelExpanded, setIsPanelExpanded] = useState(false); const [things, setThings] = useState(null); const platform = usePlatform(); const theme = useThemeContext().colorScheme; const scale = useRef(new Animated.Value(0)).current; const opacity = useRef(new Animated.Value(1)).current; + const [shipSearchFormData, setShipSearchFormData] = useState< + SearchShipResponse | undefined + >(undefined); + const [tagStatePayload, setTagStatePayload] = + useState(null); // Thêm state để quản lý tracksViewChanges const [tracksViewChanges, setTracksViewChanges] = useState(true); + useEffect(() => { + if (tagStatePayload) { + searchThings(); + } + }, [tagStatePayload]); + + useEffect(() => { + if (shipSearchFormData) { + searchThings(); + } + }, [shipSearchFormData]); + const bodySearchThings: Model.SearchThingBody = { offset: 0, limit: 50, @@ -176,6 +200,7 @@ export default function HomeScreen() { // }, [banzoneData, entityData]); // Hàm tính radius cố định khi zoom change + const calculateRadiusFromZoom = (zoom: number) => { const baseZoom = 10; const baseRadius = 100; @@ -231,37 +256,39 @@ export default function HomeScreen() { }; useEffect(() => { - if (alarmData?.level === 3) { - const loop = Animated.loop( - Animated.sequence([ - Animated.parallel([ - Animated.timing(scale, { - toValue: 3, // nở to 3 lần - duration: 1500, - useNativeDriver: true, - }), - Animated.timing(opacity, { - toValue: 0, // mờ dần - duration: 1500, - useNativeDriver: true, - }), - ]), - Animated.parallel([ - Animated.timing(scale, { - toValue: 0, - duration: 0, - useNativeDriver: true, - }), - Animated.timing(opacity, { - toValue: 1, - duration: 0, - useNativeDriver: true, - }), - ]), - ]) - ); - loop.start(); - return () => loop.stop(); + for (var thing of things?.things || []) { + if (thing.metadata?.state_level === 3) { + const loop = Animated.loop( + Animated.sequence([ + Animated.parallel([ + Animated.timing(scale, { + toValue: 3, // nở to 3 lần + duration: 1500, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, // mờ dần + duration: 1500, + useNativeDriver: true, + }), + ]), + Animated.parallel([ + Animated.timing(scale, { + toValue: 0, + duration: 0, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: 0, + useNativeDriver: true, + }), + ]), + ]) + ); + loop.start(); + return () => loop.stop(); + } } }, [things, scale, opacity]); @@ -276,26 +303,78 @@ export default function HomeScreen() { } }, [isFirstLoad]); - const handleOnPressState = (state: TagStateCallbackPayload) => { + const searchThings = async () => { + console.log("FormSearch Playload in Search Thing: ", shipSearchFormData); + // Xây dựng query state dựa trên logic bạn cung cấp - const stateNormalQuery = state.isNormal ? "normal" : ""; - const stateSosQuery = state.isSos ? "sos" : ""; - const stateWarningQuery = state.isWarning + const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : ""; + const stateSosQuery = tagStatePayload?.isSos ? "sos" : ""; + const stateWarningQuery = tagStatePayload?.isWarning ? stateNormalQuery + ",warning" : stateNormalQuery; - const stateCriticalQuery = state.isDangerous + const stateCriticalQuery = tagStatePayload?.isDangerous ? stateWarningQuery + ",critical" : stateWarningQuery; // Nếu bật tất cả filter thì không cần truyền stateQuery const stateQuery = - state.isNormal && state.isWarning && state.isDangerous && state.isSos + tagStatePayload?.isNormal && + tagStatePayload?.isWarning && + tagStatePayload?.isDangerous && + tagStatePayload?.isSos ? "" : [stateCriticalQuery, stateSosQuery].filter(Boolean).join(","); let metaFormQuery = {}; - if (state.isDisconected) + if (tagStatePayload?.isDisconected) metaFormQuery = { ...metaFormQuery, connected: false }; - + if (shipSearchFormData?.ship_name) { + metaFormQuery = { + ...metaFormQuery, + ship_name: shipSearchFormData?.ship_name, + }; + } + if (shipSearchFormData?.reg_number) { + metaFormQuery = { + ...metaFormQuery, + reg_number: shipSearchFormData?.reg_number, + }; + } + if ( + shipSearchFormData?.ship_length[0] !== 0 || + shipSearchFormData?.ship_length[1] !== 100 + ) { + metaFormQuery = { + ...metaFormQuery, + ship_length: shipSearchFormData?.ship_length, + }; + } + if ( + shipSearchFormData?.ship_power[0] !== 0 || + shipSearchFormData?.ship_power[1] !== 100000 + ) { + metaFormQuery = { + ...metaFormQuery, + ship_power: shipSearchFormData?.ship_power, + }; + } + if (shipSearchFormData?.alarm_list) { + metaFormQuery = { + ...metaFormQuery, + alarm_list: shipSearchFormData?.alarm_list, + }; + } + if (shipSearchFormData?.ship_type) { + metaFormQuery = { + ...metaFormQuery, + ship_type: shipSearchFormData?.ship_type, + }; + } + if (shipSearchFormData?.ship_group_id) { + metaFormQuery = { + ...metaFormQuery, + ship_group_id: shipSearchFormData?.ship_group_id, + }; + } // Tạo metadata query const metaStateQuery = stateQuery !== "" ? { state_level: stateQuery } : null; @@ -312,10 +391,28 @@ export default function HomeScreen() { not_empty: "ship_id", }, }; + console.log("Search Params: ", searchParams); // Gọi API tìm kiếm searchThingEventBus(searchParams); }; + const handleOnSubmitSearchForm = async (data: SearchShipResponse) => { + setShipSearchFormData(data); + setShipSearchFormOpen(false); + }; + + const hasActiveFilters = shipSearchFormData + ? shipSearchFormData.ship_name !== "" || + shipSearchFormData.reg_number !== "" || + shipSearchFormData.ship_length[0] !== 0 || + shipSearchFormData.ship_length[1] !== 100 || + shipSearchFormData.ship_power[0] !== 0 || + shipSearchFormData.ship_power[1] !== 100000 || + shipSearchFormData.ship_type !== "" || + shipSearchFormData.alarm_list !== "" || + shipSearchFormData.ship_group_id !== "" || + shipSearchFormData.group_id !== "" + : false; return ( @@ -411,7 +508,20 @@ export default function HomeScreen() { )} - + + {!isPanelExpanded && ( + } + type="primary" + size="large" + style={{ + borderRadius: 10, + backgroundColor: hasActiveFilters ? "#B8D576" : "#fff", + }} + onPress={() => setShipSearchFormOpen(true)} + > + )} + {/* @@ -420,10 +530,11 @@ export default function HomeScreen() { {/* Draggable Panel */} { console.log("Panel expanded:", expanded); + setIsPanelExpanded(expanded); }} > <> @@ -437,22 +548,44 @@ export default function HomeScreen() { (things?.metadata?.total_thing ?? 0) - (things?.metadata?.total_connected ?? 0) || 0 } - onTagPress={handleOnPressState} + onTagPress={(tagState) => { + setTagStatePayload(tagState); + }} /> - - Danh sách tàu thuyền - + + + + Danh sách tàu thuyền + + + {isPanelExpanded && ( + } + type="primary" + shape="circle" + size="middle" + style={{ + // borderRadius: 10, + backgroundColor: hasActiveFilters ? "#B8D576" : "#fff", + }} + onPress={() => setShipSearchFormOpen(true)} + > + )} + @@ -485,6 +618,12 @@ export default function HomeScreen() { + setShipSearchFormOpen(false)} + onSubmit={handleOnSubmitSearchForm} + /> ); diff --git a/components/DraggablePanel.tsx b/components/DraggablePanel.tsx index 2785ea6..207f9e7 100644 --- a/components/DraggablePanel.tsx +++ b/components/DraggablePanel.tsx @@ -1,4 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import React, { useEffect, useState } from "react"; import { Pressable, @@ -34,14 +35,10 @@ export default function DraggablePanel({ 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 bottomOffset = useBottomTabBarHeight(); 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" ); @@ -115,17 +112,13 @@ export default function DraggablePanel({ ? screenHeight - maxHeight - bottomOffset + 40 : screenHeight - minHeight - bottomOffset; - translateY.value = withSpring( - targetY, - { - damping: 20, - stiffness: 200, - }, - () => { - "worklet"; - isExpanded.value = snapToMax; - } - ); + isExpanded.value = snapToMax; + runOnJS(notifyExpandedChange)(snapToMax); + + translateY.value = withSpring(targetY, { + damping: 20, + stiffness: 50, + }); }); const animatedStyle = useAnimatedStyle(() => { @@ -138,7 +131,7 @@ export default function DraggablePanel({ useAnimatedReaction( () => { const currentHeight = screenHeight - translateY.value - bottomOffset; - return currentHeight > minHeight + 10; + return currentHeight > minHeight; }, (isCurrentlyExpanded) => { const newIcon = isCurrentlyExpanded ? "chevron-down" : "chevron-up"; @@ -210,7 +203,7 @@ const styles = StyleSheet.create({ elevation: 10, }, header: { - paddingTop: 12, + paddingTop: 8, paddingBottom: 8, paddingHorizontal: 16, alignItems: "center", diff --git a/components/Select.tsx b/components/Select.tsx index 784304c..676f354 100644 --- a/components/Select.tsx +++ b/components/Select.tsx @@ -20,16 +20,16 @@ export interface SelectOption { } export interface SelectProps { - value?: string | number; - defaultValue?: string | number; + value?: string | number | (string | number)[]; + defaultValue?: string | number | (string | number)[]; options: SelectOption[]; - onChange?: (value: string | number | undefined) => void; + onChange?: (value: string | number | (string | number)[] | undefined) => void; placeholder?: string; disabled?: boolean; loading?: boolean; allowClear?: boolean; showSearch?: boolean; - mode?: "single" | "multiple"; // multiple not implemented yet + mode?: "single" | "multiple"; style?: StyleProp; size?: "small" | "middle" | "large"; listStyle?: StyleProp; @@ -38,7 +38,7 @@ export interface SelectProps { /** * Select * A Select component inspired by Ant Design, adapted for React Native. - * Supports single selection, search, clear, loading, disabled states. + * Supports single and multiple selection, search, clear, loading, disabled states. */ const Select: React.FC = ({ value, @@ -55,16 +55,23 @@ const Select: React.FC = ({ listStyle, size = "middle", }) => { - const [selectedValue, setSelectedValue] = useState< - string | number | undefined - >(value ?? defaultValue); + const initialValue = value ?? defaultValue; + const [selectedValues, setSelectedValues] = useState<(string | number)[]>( + Array.isArray(initialValue) + ? initialValue + : initialValue + ? [initialValue] + : [] + ); const [isOpen, setIsOpen] = useState(false); const [searchText, setSearchText] = useState(""); const [containerHeight, setContainerHeight] = useState(0); + const [textHeight, setTextHeight] = useState(0); useEffect(() => { - setSelectedValue(value); - }, [value]); + const newVal = value ?? defaultValue; + setSelectedValues(Array.isArray(newVal) ? newVal : newVal ? [newVal] : []); + }, [value, defaultValue]); const filteredOptions = showSearch ? options.filter((opt) => @@ -72,17 +79,31 @@ const Select: React.FC = ({ ) : options; - const selectedOption = options.find((opt) => opt.value === selectedValue); - const handleSelect = (val: string | number) => { - setSelectedValue(val); - onChange?.(val); - setIsOpen(false); - setSearchText(""); + let newSelected: (string | number)[]; + if (mode === "single") { + newSelected = [val]; + } else { + newSelected = selectedValues.includes(val) + ? selectedValues.filter((v) => v !== val) + : [...selectedValues, val]; + } + setSelectedValues(newSelected); + onChange?.( + mode === "single" + ? newSelected.length > 0 + ? newSelected[0] + : undefined + : newSelected + ); + if (mode === "single") { + setIsOpen(false); + setSearchText(""); + } }; const handleClear = () => { - setSelectedValue(undefined); + setSelectedValues([]); onChange?.(undefined); }; @@ -100,13 +121,26 @@ const Select: React.FC = ({ ? colors.backgroundSecondary : colors.surface; + let displayText = placeholder; + if (selectedValues.length > 0) { + if (mode === "single") { + const opt = options.find((o) => o.value === selectedValues[0]); + displayText = opt?.label || placeholder; + } else { + const labels = selectedValues + .map((v) => options.find((o) => o.value === v)?.label) + .filter(Boolean); + displayText = labels.join(", "); + } + } + return ( = ({ fontSize: sz.fontSize, color: disabled ? colors.textSecondary - : selectedValue + : selectedValues.length > 0 ? colors.text : colors.textSecondary, }, ]} - numberOfLines={1} + onLayout={(e) => setTextHeight(e.nativeEvent.layout.height)} > - {selectedOption?.label || placeholder} + {displayText} )} - {allowClear && selectedValue && !loading ? ( + {allowClear && selectedValues.length > 0 && !loading ? ( @@ -193,7 +227,7 @@ const Select: React.FC = ({ borderBottomColor: colors.separator, }, item.disabled && styles.optionDisabled, - selectedValue === item.value && { + selectedValues.includes(item.value) && { backgroundColor: colors.primary + "20", // Add transparency to primary color }, ]} @@ -209,7 +243,7 @@ const Select: React.FC = ({ item.disabled && { color: colors.textSecondary, }, - selectedValue === item.value && { + selectedValues.includes(item.value) && { color: colors.primary, fontWeight: "600", }, @@ -217,7 +251,7 @@ const Select: React.FC = ({ > {item.label} - {selectedValue === item.value && ( + {selectedValues.includes(item.value) && ( )} diff --git a/components/ShipSearchForm.tsx b/components/ShipSearchForm.tsx index a149b70..edff56f 100644 --- a/components/ShipSearchForm.tsx +++ b/components/ShipSearchForm.tsx @@ -91,8 +91,14 @@ const ShipSearchForm = (props: ShipSearchFormProps) => { reset({ ship_name: props.initialValues.ship_name || "", reg_number: props.initialValues.reg_number || "", - ship_length: [0, 100], - ship_power: [0, 100000], + ship_length: [ + props.initialValues.ship_length?.[0] || 0, + props.initialValues.ship_length?.[1] || 100, + ], + ship_power: [ + props.initialValues.ship_power?.[0] || 0, + props.initialValues.ship_power?.[1] || 100000, + ], ship_type: props.initialValues.ship_type || "", alarm_list: props.initialValues.alarm_list || "", ship_group_id: props.initialValues.ship_group_id || "", @@ -129,7 +135,9 @@ const ShipSearchForm = (props: ShipSearchFormProps) => { const onSubmitForm = (data: SearchShipResponse) => { props.onSubmit?.(data); - props.onClose(); + console.log("Data: ", data); + + // props.onClose(); }; const onReset = () => { @@ -317,6 +325,7 @@ const ShipSearchForm = (props: ShipSearchFormProps) => { value: type.id || 0, }))} placeholder="Chọn loại tàu" + mode="multiple" value={value} onChange={onChange} /> @@ -339,6 +348,7 @@ const ShipSearchForm = (props: ShipSearchFormProps) => { value: type.value || "", }))} placeholder="Chọn loại cảnh báo" + mode="multiple" value={value} onChange={onChange} /> @@ -361,12 +371,14 @@ const ShipSearchForm = (props: ShipSearchFormProps) => { value: group.id || "", }))} placeholder="Chọn đội tàu" + mode="multiple" value={value} onChange={onChange} /> )} /> + diff --git a/components/Slider.tsx b/components/Slider.tsx index 5a36db5..988d67e 100644 --- a/components/Slider.tsx +++ b/components/Slider.tsx @@ -290,12 +290,13 @@ export default function Slider({ {Object.entries(marks).map(([key, label]) => { const val = Number(key); const pos = getPositionFromValue(val); + const leftPos = Math.max(0, Math.min(pos - 10, width - 40)); return (