diff --git a/TAG_SEARCH_SHIP.md b/TAG_SEARCH_SHIP.md new file mode 100644 index 0000000..6056c62 --- /dev/null +++ b/TAG_SEARCH_SHIP.md @@ -0,0 +1,25 @@ +# Prompt: Tạo một component tags ở `components/ui/ShipSearchForm` + +## Mục tiêu + +- Tạo một component ShipSearchForm để: + - Cho người dùng nhập vào các trường Tên tàu, Số đăng ký, Chiều dài, Công suất, Loại tàu, Cảnh báo, Đội tàu + - Tên tàu và Số đăng ký là input cho người dùng nhập vào + - Nếu số lượng truyền vào bằng 0 thì sẽ không hiển thị + - Có callback khi nhấn vào tag + - Quy định màu: + - Bình thường: + - Chưa nhấn: nền trắng, chữ xanh, màu viền xanh lá + - Khi nhấn: nền xanh lá cây, chữ trắng, màu viền xanh lá + - Cảnh báo + - Chưa nhấn: nền trắng, chữ vàng, màu viền vàng + - Khi nhấn: nền vàng, chữ trắng, màu viền vàng + - Nguy hiểm + - Chưa nhấn: nền trắng, chữ cam, màu viền cam + - Khi nhấn: nền cam, chữ trắng, màu viền cam + - SOS + - Chưa nhấn: nền trắng, chữ đỏ, màu viền đỏ + - Khi nhấn: nền đỏ, chữ trắng, màu viền đỏ + - Mất kết nối + - Chưa nhấn: nền trắng, chữ xám đậm, màu viền xám đậm + - Khi nhấn: nền xám đậm, chữ trắng, màu viền xám đậm diff --git a/TAG_STATE_TASK.md b/TAG_STATE_TASK.md new file mode 100644 index 0000000..b0c8b47 --- /dev/null +++ b/TAG_STATE_TASK.md @@ -0,0 +1,25 @@ +# Prompt: Tạo một component tags ở `components/map` + +## Mục tiêu + +- Tạo một component TagState để: + - Mình có thể hiển thị số lượng thiết bị Bình thường, Cảnh báo, Nguy hiểm, SOS, Mất kết nối + - Mình sẽ truyền vào số lượng các thiết bị ở các trạng thái + - Nếu số lượng truyền vào bằng 0 thì sẽ không hiển thị + - Có callback khi nhấn vào tag + - Quy định màu: + - Bình thường: + - Chưa nhấn: nền trắng, chữ xanh, màu viền xanh lá + - Khi nhấn: nền xanh lá cây, chữ trắng, màu viền xanh lá + - Cảnh báo + - Chưa nhấn: nền trắng, chữ vàng, màu viền vàng + - Khi nhấn: nền vàng, chữ trắng, màu viền vàng + - Nguy hiểm + - Chưa nhấn: nền trắng, chữ cam, màu viền cam + - Khi nhấn: nền cam, chữ trắng, màu viền cam + - SOS + - Chưa nhấn: nền trắng, chữ đỏ, màu viền đỏ + - Khi nhấn: nền đỏ, chữ trắng, màu viền đỏ + - Mất kết nối + - Chưa nhấn: nền trắng, chữ xám đậm, màu viền xám đậm + - Khi nhấn: nền xám đậm, chữ trắng, màu viền xám đậm diff --git a/Task.md b/Task.md new file mode 100644 index 0000000..9c9e56d --- /dev/null +++ b/Task.md @@ -0,0 +1,87 @@ +# Prompt: Tạo bottom sheet kéo/thả trên Map ở `app/(tabs)/index.tsx` + +## Mục tiêu + +- Thêm một component dạng bottom sheet nổi trên `MapView`, có thể nhấn (click) để mở nhanh và kéo (swipe) để thay đổi chiều cao mượt mà. + +## Ngữ cảnh + +- Dự án Expo React Native (TypeScript) với màn hình map tại: `app/(tabs)/index.tsx`. +- Cần chèn một panel nổi (overlay) bám đáy màn hình, hiển thị trên MapView. + +## Yêu cầu chức năng + +- Chiều cao ban đầu: 10% chiều cao màn hình. +- Chiều cao tối đa: 50% chiều cao màn hình. +- Có một nút ở góc trên bên phải của panel để “mở/thu” (move up/down). +- Khi người dùng click vào panel hoặc click nút: panel mở ngay lập tức lên 50%. +- Khi người dùng vuốt (kéo) panel lên/xuống: chiều cao thay đổi dần, bị chặn trong khoảng [10%, 50%]. +- Hiệu ứng chuyển đổi chiều cao phải mượt mà, không gây khó chịu (ease-in-out, 180–300ms). +- Panel chỉ nhận touch trong vùng của nó, không chặn thao tác trên map ở các vùng khác. + +## Yêu cầu UI/UX + +- Panel bám đáy màn hình, bo tròn góc trên (ví dụ: borderTopLeftRadius/borderTopRightRadius = 16–20). +- Có bóng đổ nhẹ để nổi trên map. +- Có “khu vực kéo” (drag handle) ở header panel (có thể là dải ngang mảnh) + nút toggle ở góc trên bên phải. +- Nội dung bên trong panel có thể là placeholder đơn giản (ví dụ một vài dòng text) để kiểm chứng hành vi. + +## Ràng buộc kỹ thuật + +- Ưu tiên dùng `react-native-gesture-handler` + `react-native-reanimated` để có tương tác kéo mượt (UI thread). Nếu không khả dụng, fallback sang `Animated` + `PanResponder` vẫn được. +- Không thêm UI library bên thứ ba cho bottom sheet (ví dụ `@gorhom/bottom-sheet`) — tự triển khai component nhẹ. +- TypeScript, functional components, tuân thủ style hiện có (không thay đổi kiến trúc không cần thiết). + +## Hướng triển khai (gợi ý) + +1. Tạo component `components/DraggablePanel.tsx` với các props: + - `minHeightPct?: number` (mặc định 0.1) + - `maxHeightPct?: number` (mặc định 0.5) + - `initialState?: 'min' | 'max'` (mặc định 'min') + - `onExpandedChange?: (expanded: boolean) => void` +2. Trong `DraggablePanel`, tính chiều cao theo phần trăm màn hình bằng `useWindowDimensions` hoặc `Dimensions.get('window').height`. +3. Nếu dùng Reanimated: dùng `useSharedValue` cho height hoặc translateY, `withTiming` khi click, và `withSpring`/`withTiming` khi kéo. Clamp giá trị vào [min, max]. +4. Nếu dùng Animated: dùng `Animated.Value` + `Animated.timing` và `PanResponder` để cập nhật height theo cử chỉ, clamp trong [min, max]. +5. Thêm header có drag handle và nút toggle ở góc trên phải: + - Click header hoặc click nút: toggle giữa min (10%) và max (50%). + - Vuốt: cập nhật theo delta cử chỉ; khi thả tay nếu vượt ngưỡng giữa thì snap về 50%, ngược lại về 10%. +6. Đặt panel overlay lên Map ở `app/(tabs)/index.tsx` bằng `position: 'absolute'`, `left: 0`, `right: 0`, `bottom: 0`, `height` do animation điều khiển. Đảm bảo `pointerEvents` phù hợp để không chặn thao tác trên map bên ngoài panel. + +## Tiêu chí chấp nhận (Acceptance Criteria) + +- Panel xuất hiện trên Map, bám đáy, bo góc trên, có bóng đổ nhẹ. +- Chiều cao ban đầu là ~10% màn hình; click mở hoặc click nút toggle lập tức chuyển lên ~50% (animation ~200ms, mượt). +- Vuốt kéo panel thay đổi chiều cao theo ngón tay, bị chặn trong khoảng [10%, 50%]. Thả tay, panel snap về 10% hoặc 50% theo vị trí/độ nhanh. +- Nút ở góc trên phải hoạt động đúng: chuyển qua lại giữa 10% và 50%. +- Tương tác map vẫn bình thường ở vùng ngoài panel; trong panel, vuốt không làm map pan. +- Không crash, không warning/ lỗi TypeScript mới do thay đổi này. + +## Bàn giao + +- File mới: `components/DraggablePanel.tsx`. +- Sửa file: `app/(tabs)/index.tsx` để chèn panel overlay. +- Không thay đổi các phần không liên quan. +- Nếu cần cài đặt `react-native-reanimated`/`react-native-gesture-handler`, cung cấp hướng dẫn và chỉnh cấu hình cần thiết cho Expo (nhưng chỉ thực hiện nếu bắt buộc). + +## Gợi ý kiểm thử thủ công + +- iOS và Android (nếu có): + - Mở app, xác nhận panel đang ở 10%. + - Click vào panel hoặc nút: panel mở lên 50% với animation mượt. + - Kéo lên/xuống nhiều lần: độ cao thay đổi mượt, không vượt quá 50% hoặc thấp hơn 10%. + - Pan/zoom Map ở vùng ngoài panel: vẫn hoạt động bình thường. + - Xoay màn hình (nếu hỗ trợ): panel tính lại theo chiều cao mới. + +## Lưu ý + +- Giữ code gọn, tách logic cử chỉ/animation khỏi phần trình bày nếu hợp lý. +- Đặt tên biến/props rõ ràng, không dùng one-letter variable. +- Không thêm license header hoặc thay đổi cấu trúc dự án nếu không cần thiết. + +- Mục tiêu: Tạo một component có thể di chuyển lên xuống +- Bài toán: Ở giao diện index.tsx, đã có component MapView + - Giờ mình muốn thêm một component nổi lên trên map, ban đầu sẽ có chiều cao 10% chiều cao màn hình, giống như modal của Expo + - Trên giao diện cho một nút di chuyển lên/ xuống ở góc trên bên phải + - Khi người dùng click vào hoặc vuốt component lên thì chiều cao của thẻ cũng thay đổi, max là 50% chiều cao màn hình + - Khi click thì chiều cao sẽ đổi thành 50% luôn, còn vuốt thì chiều cao sẽ tăng dần + - Hiệu ứng thay đổi chiều cao mượt mà, không gây khó chịu cho người dùng diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6086393..4315f09 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,18 +1,28 @@ +import DraggablePanel from "@/components/DraggablePanel"; import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel"; import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel"; -import { IOS_PLATFORM, LIGHT_THEME } from "@/constants"; +import ShipInfo from "@/components/map/ShipInfo"; +import { TagState, TagStateCallbackPayload } from "@/components/map/TagState"; +import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants"; import { usePlatform } from "@/hooks/use-platform"; import { useThemeContext } from "@/hooks/use-theme-context"; -import { useRef, useState } from "react"; -import { Animated, StyleSheet, View } from "react-native"; -import MapView from "react-native-maps"; +import { searchThingEventBus } from "@/services/device_events"; +import { getShipIcon } from "@/services/map_service"; +import eventBus from "@/utils/eventBus"; +import { useEffect, useRef, useState } from "react"; +import { + Animated, + Image, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import MapView, { Marker } from "react-native-maps"; export default function HomeScreen() { - const [gpsData, setGpsData] = useState(null); const [alarmData, setAlarmData] = useState(null); - const [entityData, setEntityData] = useState< - Model.TransformedEntity[] | null - >(null); const [banzoneData, setBanzoneData] = useState(null); const [trackPointsData, setTrackPointsData] = useState< Model.ShipTrackPoint[] | null @@ -26,74 +36,64 @@ export default function HomeScreen() { const [polygonCoordinates, setPolygonCoordinates] = useState< PolygonWithLabelProps[] >([]); + 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; - // useEffect(() => { - // getGpsEventBus(); - // getAlarmEventBus(); - // getEntitiesEventBus(); - // getBanzonesEventBus(); - // getTrackPointsEventBus(); - // const queryGpsData = (gpsData: Model.GPSResponse) => { - // if (gpsData) { - // // console.log("GPS Data: ", gpsData); - // setGpsData(gpsData); - // } else { - // setGpsData(null); - // setPolygonCoordinates([]); - // setPolylineCoordinates([]); - // } - // }; - // const queryAlarmData = (alarmData: Model.AlarmResponse) => { - // // console.log("Alarm Data: ", alarmData.alarms.length); - // setAlarmData(alarmData); - // }; - // const queryEntityData = (entityData: Model.TransformedEntity[]) => { - // // console.log("Entities Length Data: ", entityData.length); - // setEntityData(entityData); - // }; - // const queryBanzonesData = (banzoneData: Model.Zone[]) => { - // // console.log("Banzone Data: ", banzoneData.length); + // Thêm state để quản lý tracksViewChanges + const [tracksViewChanges, setTracksViewChanges] = useState(true); - // setBanzoneData(banzoneData); - // }; - // const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => { - // // console.log("TrackPoints Data: ", TrackPointsData.length); - // if (TrackPointsData && TrackPointsData.length > 0) { - // setTrackPointsData(TrackPointsData); - // } else { - // setTrackPointsData([]); - // } - // }; + const bodySearchThings: Model.SearchThingBody = { + offset: 0, + limit: 50, + order: "name", + sort: "asc", + metadata: { + not_empty: "ship_id", + }, + }; - // eventBus.on(EVENT_GPS_DATA, queryGpsData); - // // console.log("Registering event handlers in HomeScreen"); - // eventBus.on(EVENT_GPS_DATA, queryGpsData); - // // console.log("Subscribed to EVENT_GPS_DATA"); - // eventBus.on(EVENT_ALARM_DATA, queryAlarmData); - // // console.log("Subscribed to EVENT_ALARM_DATA"); - // eventBus.on(EVENT_ENTITY_DATA, queryEntityData); - // // console.log("Subscribed to EVENT_ENTITY_DATA"); - // eventBus.on(EVENT_TRACK_POINTS_DATA, queryTrackPointsData); - // // console.log("Subscribed to EVENT_TRACK_POINTS_DATA"); - // eventBus.once(EVENT_BANZONE_DATA, queryBanzonesData); - // // console.log("Subscribed once to EVENT_BANZONE_DATA"); + useEffect(() => { + searchThingEventBus(bodySearchThings); + const querySearchThingsData = (thingsData: Model.ThingsResponse) => { + const sortedThings: Model.Thing[] = (thingsData.things ?? []).sort( + (a, b) => { + const stateLevelA = a.metadata?.state_level || 0; + const stateLevelB = b.metadata?.state_level || 0; + return stateLevelB - stateLevelA; // Giảm dần + } + ); + const sortedThingsResponse: Model.ThingsResponse = { + ...thingsData, + things: sortedThings, + }; + console.log("Things Updated: ", sortedThingsResponse.things?.length); - // return () => { - // // console.log("Unregistering event handlers in HomeScreen"); - // eventBus.off(EVENT_GPS_DATA, queryGpsData); - // // console.log("Unsubscribed EVENT_GPS_DATA"); - // eventBus.off(EVENT_ALARM_DATA, queryAlarmData); - // // console.log("Unsubscribed EVENT_ALARM_DATA"); - // eventBus.off(EVENT_ENTITY_DATA, queryEntityData); - // // console.log("Unsubscribed EVENT_ENTITY_DATA"); - // eventBus.off(EVENT_TRACK_POINTS_DATA, queryTrackPointsData); - // // console.log("Unsubscribed EVENT_TRACK_POINTS_DATA"); - // }; - // }, []); + setThings(sortedThingsResponse); + }; + eventBus.on(EVENT_SEARCH_THINGS, querySearchThingsData); + + return () => { + eventBus.off(EVENT_SEARCH_THINGS, querySearchThingsData); + }; + }, []); + + useEffect(() => { + if (things) { + // console.log("Things Updated: ", things.things?.length); + // const gpsDatas: Model.GPSResponse[] = []; + // for (const thing of things.things || []) { + // if (thing.metadata?.gps) { + // const gps: Model.GPSResponse = JSON.parse(thing.metadata.gps); + // gpsDatas.push(gps); + // } + // } + // console.log("GPS Lenght: ", gpsDatas.length); + // setGpsData(gpsDatas); + } + }, [things]); // useEffect(() => { // setPolylineCoordinates([]); @@ -203,7 +203,7 @@ export default function HomeScreen() { // Sau lần đầu, return undefined để không force region return undefined; } - if (!gpsData) { + if (things?.things?.length === 0) { return { latitude: 15.70581, longitude: 116.152685, @@ -213,8 +213,12 @@ export default function HomeScreen() { } return { - latitude: gpsData.lat, - longitude: gpsData.lon, + latitude: things?.things?.[0]?.metadata?.gps + ? JSON.parse(things.things[0].metadata.gps).lat + : 15.70581, + longitude: things?.things?.[0]?.metadata?.gps + ? JSON.parse(things.things[0].metadata.gps).lon + : 116.152685, latitudeDelta: 0.05, longitudeDelta: 0.05, }; @@ -226,184 +230,263 @@ export default function HomeScreen() { }, 2000); }; - // 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(); - // } - // }, [alarmData?.level, scale, opacity]); + 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(); + } + }, [things, scale, opacity]); + + // Tắt tracksViewChanges sau khi map đã load xong + useEffect(() => { + if (!isFirstLoad) { + // Delay một chút để đảm bảo markers đã render xong + const timer = setTimeout(() => { + setTracksViewChanges(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [isFirstLoad]); + + const handleOnPressState = (state: TagStateCallbackPayload) => { + // 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 + ? stateNormalQuery + ",warning" + : stateNormalQuery; + const stateCriticalQuery = state.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 + ? "" + : [stateCriticalQuery, stateSosQuery].filter(Boolean).join(","); + let metaFormQuery = {}; + if (state.isDisconected) + metaFormQuery = { ...metaFormQuery, connected: false }; + + // Tạo metadata query + const metaStateQuery = + stateQuery !== "" ? { state_level: stateQuery } : null; + + // Tạo body search với filter + const searchParams: Model.SearchThingBody = { + offset: 0, + limit: 50, + order: "name", + sort: "asc", + metadata: { + ...metaFormQuery, + ...metaStateQuery, + not_empty: "ship_id", + }, + }; + + // Gọi API tìm kiếm + searchThingEventBus(searchParams); + }; return ( - - - {/* {trackPointsData && - trackPointsData.length > 0 && - trackPointsData.map((point, index) => { - // console.log(`Rendering circle ${index}:`, point); - return ( - - ); - })} - {polylineCoordinates.length > 0 && ( - <> - {polylineCoordinates.map((polyline, index) => ( - - ))} - - )} - {polygonCoordinates.length > 0 && ( - <> - {polygonCoordinates.map((polygon, index) => { - return ( - - ); - })} - - )} */} - {/* {gpsData !== null && ( - - - - {alarmData?.level === 3 && ( - - )} - { - const icon = getShipIcon( - alarmData?.level || 0, - gpsData.fishing - ); - // console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing); - return typeof icon === "string" ? { uri: icon } : icon; - })()} - style={{ - width: 32, - height: 32, - transform: [ - { - rotate: `${ - typeof gpsData.h === "number" && !isNaN(gpsData.h) - ? gpsData.h - : 0 - }deg`, - }, - ], - }} - /> - - - - )} */} - + + + + {things?.things && things.things.length > 0 && ( + <> + {things.things + .filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check + .map((thing, index) => { + const gpsData: Model.GPSResponse = JSON.parse( + thing.metadata!.gps! + ); - {/* - + // Tạo unique key dựa trên thing.id hoặc tọa độ + const uniqueKey = thing.id + ? `marker-${thing.id}-${index}` + : `marker-${gpsData.lat.toFixed(6)}-${gpsData.lon.toFixed( + 6 + )}-${index}`; + + return ( + + + + {thing.metadata?.state_level === 3 && ( + + )} + { + const icon = getShipIcon( + thing.metadata?.state_level || 0, + gpsData.fishing + ); + // console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing); + return typeof icon === "string" + ? { uri: icon } + : icon; + })()} + style={{ + width: 32, + height: 32, + transform: [ + { + rotate: `${ + typeof gpsData.h === "number" && + !isNaN(gpsData.h) + ? gpsData.h + : 0 + }deg`, + }, + ], + }} + /> + + + + ); + })} + + )} + + + {/* + + + */} + + {/* Draggable Panel */} + { + console.log("Panel expanded:", expanded); + }} + > + <> + + + + + + Danh sách tàu thuyền + + + + {things && things.things && things.things.length > 0 && ( + <> + {things.things.map((thing, index) => { + return ( + + + {index < (things.things?.length ?? 0) - 1 && ( + + )} + + ); + })} + + )} + + + + - */} - + ); } diff --git a/app/(tabs)/warning.tsx b/app/(tabs)/warning.tsx index 6ecd9b4..66874c0 100644 --- a/app/(tabs)/warning.tsx +++ b/app/(tabs)/warning.tsx @@ -1,13 +1,20 @@ +import ShipSearchForm from "@/components/ShipSearchForm"; +import { useState } from "react"; import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; export default function warning() { + const [shipSearchFormOpen, setShipSearchFormOpen] = useState(true); return ( Cảnh báo + setShipSearchFormOpen(false)} + /> ); diff --git a/app/_layout.tsx b/app/_layout.tsx index 0569a30..d9de09d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,7 +14,10 @@ import { toastConfig } from "@/config"; import { setRouterInstance } from "@/config/auth"; import "@/global.css"; import { I18nProvider } from "@/hooks/use-i18n"; -import { ThemeProvider as AppThemeProvider, useThemeContext } from "@/hooks/use-theme-context"; +import { + ThemeProvider as AppThemeProvider, + useThemeContext, +} from "@/hooks/use-theme-context"; import Toast from "react-native-toast-message"; import "../global.css"; function AppContent() { diff --git a/components/DraggablePanel.tsx b/components/DraggablePanel.tsx new file mode 100644 index 0000000..2785ea6 --- /dev/null +++ b/components/DraggablePanel.tsx @@ -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 ( + + + {/* Header với drag handle và nút toggle */} + + + + + + + + + + {/* Nội dung */} + + {children || ( + + Draggable Panel + + Click hoặc kéo để mở rộng panel này + + + Min: {(minHeightPct * 100).toFixed(0)}% | Max:{" "} + {(maxHeightPct * 100).toFixed(0)}% + + + )} + + + + ); +} + +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, + }, +}); diff --git a/components/ShipSearchForm.tsx b/components/ShipSearchForm.tsx new file mode 100644 index 0000000..a149b70 --- /dev/null +++ b/components/ShipSearchForm.tsx @@ -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; + 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([]); + const [slideAnim] = useState(new Animated.Value(0)); + + const { control, handleSubmit, reset, watch } = useForm({ + 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 ( + + + + true} + > + {/* Header */} + + + + Tìm kiếm tàu + + + + ✕ + + + + + {/* Form Content */} + + + {/* Tên tàu */} + + + Tên tàu + + ( + + )} + /> + + + {/* Số đăng ký */} + + + Số đăng ký + + ( + + )} + /> + + + {/* Chiều dài */} + + + Chiều dài ({shipLengthValue[0]}m - {shipLengthValue[1]}m) + + ( + + + onChange(val as [number, number]) + } + activeColor={colors.primary} + /> + + )} + /> + + + {/* Công suất */} + + + Công suất ({shipPowerValue[0]}kW - {shipPowerValue[1]}kW) + + ( + + + onChange(val as [number, number]) + } + activeColor={colors.primary} + /> + + )} + /> + + + {/* Loại tàu */} + + + Loại tàu + + ( + ({ + label: type.label || "", + value: type.value || "", + }))} + placeholder="Chọn loại cảnh báo" + value={value} + onChange={onChange} + /> + )} + /> + + + {/* Đội tàu */} + + + Đội tàu + + ( +