hiển thị thuyền thông tin tàu
This commit is contained in:
25
TAG_SEARCH_SHIP.md
Normal file
25
TAG_SEARCH_SHIP.md
Normal file
@@ -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
|
||||||
25
TAG_STATE_TASK.md
Normal file
25
TAG_STATE_TASK.md
Normal file
@@ -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
|
||||||
87
Task.md
Normal file
87
Task.md
Normal file
@@ -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
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
|
import DraggablePanel from "@/components/DraggablePanel";
|
||||||
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
||||||
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
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 { usePlatform } from "@/hooks/use-platform";
|
||||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { useRef, useState } from "react";
|
import { searchThingEventBus } from "@/services/device_events";
|
||||||
import { Animated, StyleSheet, View } from "react-native";
|
import { getShipIcon } from "@/services/map_service";
|
||||||
import MapView from "react-native-maps";
|
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() {
|
export default function HomeScreen() {
|
||||||
const [gpsData, setGpsData] = useState<Model.GPSResponse | null>(null);
|
|
||||||
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
||||||
const [entityData, setEntityData] = useState<
|
|
||||||
Model.TransformedEntity[] | null
|
|
||||||
>(null);
|
|
||||||
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
||||||
const [trackPointsData, setTrackPointsData] = useState<
|
const [trackPointsData, setTrackPointsData] = useState<
|
||||||
Model.ShipTrackPoint[] | null
|
Model.ShipTrackPoint[] | null
|
||||||
@@ -26,74 +36,64 @@ export default function HomeScreen() {
|
|||||||
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||||
PolygonWithLabelProps[]
|
PolygonWithLabelProps[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const theme = useThemeContext().colorScheme;
|
const theme = useThemeContext().colorScheme;
|
||||||
const scale = useRef(new Animated.Value(0)).current;
|
const scale = useRef(new Animated.Value(0)).current;
|
||||||
const opacity = useRef(new Animated.Value(1)).current;
|
const opacity = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
// useEffect(() => {
|
// Thêm state để quản lý tracksViewChanges
|
||||||
// getGpsEventBus();
|
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
||||||
// getAlarmEventBus();
|
|
||||||
// getEntitiesEventBus();
|
const bodySearchThings: Model.SearchThingBody = {
|
||||||
// getBanzonesEventBus();
|
offset: 0,
|
||||||
// getTrackPointsEventBus();
|
limit: 50,
|
||||||
// const queryGpsData = (gpsData: Model.GPSResponse) => {
|
order: "name",
|
||||||
// if (gpsData) {
|
sort: "asc",
|
||||||
// // console.log("GPS Data: ", gpsData);
|
metadata: {
|
||||||
// setGpsData(gpsData);
|
not_empty: "ship_id",
|
||||||
// } else {
|
},
|
||||||
// setGpsData(null);
|
};
|
||||||
// setPolygonCoordinates([]);
|
|
||||||
// setPolylineCoordinates([]);
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
// }
|
// }
|
||||||
// };
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// setBanzoneData(banzoneData);
|
|
||||||
// };
|
|
||||||
// const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
|
|
||||||
// // console.log("TrackPoints Data: ", TrackPointsData.length);
|
|
||||||
// if (TrackPointsData && TrackPointsData.length > 0) {
|
|
||||||
// setTrackPointsData(TrackPointsData);
|
|
||||||
// } else {
|
|
||||||
// setTrackPointsData([]);
|
|
||||||
// }
|
// }
|
||||||
// };
|
// console.log("GPS Lenght: ", gpsDatas.length);
|
||||||
|
// setGpsData(gpsDatas);
|
||||||
// eventBus.on(EVENT_GPS_DATA, queryGpsData);
|
}
|
||||||
// // console.log("Registering event handlers in HomeScreen");
|
}, [things]);
|
||||||
// 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");
|
|
||||||
|
|
||||||
// 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");
|
|
||||||
// };
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// setPolylineCoordinates([]);
|
// setPolylineCoordinates([]);
|
||||||
@@ -203,7 +203,7 @@ export default function HomeScreen() {
|
|||||||
// Sau lần đầu, return undefined để không force region
|
// Sau lần đầu, return undefined để không force region
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!gpsData) {
|
if (things?.things?.length === 0) {
|
||||||
return {
|
return {
|
||||||
latitude: 15.70581,
|
latitude: 15.70581,
|
||||||
longitude: 116.152685,
|
longitude: 116.152685,
|
||||||
@@ -213,8 +213,12 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
latitude: gpsData.lat,
|
latitude: things?.things?.[0]?.metadata?.gps
|
||||||
longitude: gpsData.lon,
|
? 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,
|
latitudeDelta: 0.05,
|
||||||
longitudeDelta: 0.05,
|
longitudeDelta: 0.05,
|
||||||
};
|
};
|
||||||
@@ -226,51 +230,100 @@ export default function HomeScreen() {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// if (alarmData?.level === 3) {
|
if (alarmData?.level === 3) {
|
||||||
// const loop = Animated.loop(
|
const loop = Animated.loop(
|
||||||
// Animated.sequence([
|
Animated.sequence([
|
||||||
// Animated.parallel([
|
Animated.parallel([
|
||||||
// Animated.timing(scale, {
|
Animated.timing(scale, {
|
||||||
// toValue: 3, // nở to 3 lần
|
toValue: 3, // nở to 3 lần
|
||||||
// duration: 1500,
|
duration: 1500,
|
||||||
// useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
// }),
|
}),
|
||||||
// Animated.timing(opacity, {
|
Animated.timing(opacity, {
|
||||||
// toValue: 0, // mờ dần
|
toValue: 0, // mờ dần
|
||||||
// duration: 1500,
|
duration: 1500,
|
||||||
// useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
// }),
|
}),
|
||||||
// ]),
|
]),
|
||||||
// Animated.parallel([
|
Animated.parallel([
|
||||||
// Animated.timing(scale, {
|
Animated.timing(scale, {
|
||||||
// toValue: 0,
|
toValue: 0,
|
||||||
// duration: 0,
|
duration: 0,
|
||||||
// useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
// }),
|
}),
|
||||||
// Animated.timing(opacity, {
|
Animated.timing(opacity, {
|
||||||
// toValue: 1,
|
toValue: 1,
|
||||||
// duration: 0,
|
duration: 0,
|
||||||
// useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
// }),
|
}),
|
||||||
// ]),
|
]),
|
||||||
// ])
|
])
|
||||||
// );
|
);
|
||||||
// loop.start();
|
loop.start();
|
||||||
// return () => loop.stop();
|
return () => loop.stop();
|
||||||
// }
|
}
|
||||||
// }, [alarmData?.level, scale, opacity]);
|
}, [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 (
|
return (
|
||||||
<View
|
<GestureHandlerRootView style={styles.container}>
|
||||||
// edges={["top"]}
|
<View style={styles.container}>
|
||||||
style={styles.container}
|
|
||||||
>
|
|
||||||
<MapView
|
<MapView
|
||||||
onMapReady={handleMapReady}
|
onMapReady={handleMapReady}
|
||||||
onRegionChangeComplete={handleRegionChangeComplete}
|
onRegionChangeComplete={handleRegionChangeComplete}
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
// initialRegion={getMapRegion()}
|
|
||||||
region={getMapRegion()}
|
region={getMapRegion()}
|
||||||
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
|
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
|
||||||
showsBuildings={false}
|
showsBuildings={false}
|
||||||
@@ -279,87 +332,41 @@ export default function HomeScreen() {
|
|||||||
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
||||||
rotateEnabled={false}
|
rotateEnabled={false}
|
||||||
>
|
>
|
||||||
{/* {trackPointsData &&
|
{things?.things && things.things.length > 0 && (
|
||||||
trackPointsData.length > 0 &&
|
|
||||||
trackPointsData.map((point, index) => {
|
|
||||||
// console.log(`Rendering circle ${index}:`, point);
|
|
||||||
return (
|
|
||||||
<Circle
|
|
||||||
key={`circle-${index}`}
|
|
||||||
center={{
|
|
||||||
latitude: point.lat,
|
|
||||||
longitude: point.lon,
|
|
||||||
}}
|
|
||||||
// zIndex={50}
|
|
||||||
// radius={platform === IOS_PLATFORM ? 200 : 50}
|
|
||||||
radius={circleRadius}
|
|
||||||
strokeColor="rgba(16, 85, 201, 0.7)"
|
|
||||||
fillColor="rgba(16, 85, 201, 0.7)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{polylineCoordinates.length > 0 && (
|
|
||||||
<>
|
<>
|
||||||
{polylineCoordinates.map((polyline, index) => (
|
{things.things
|
||||||
<PolylineWithLabel
|
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
|
||||||
key={`polyline-${index}-${gpsData?.lat || 0}-${
|
.map((thing, index) => {
|
||||||
gpsData?.lon || 0
|
const gpsData: Model.GPSResponse = JSON.parse(
|
||||||
}`}
|
thing.metadata!.gps!
|
||||||
coordinates={polyline.coordinates}
|
|
||||||
label={polyline.label}
|
|
||||||
content={polyline.content}
|
|
||||||
strokeColor="#FF5733"
|
|
||||||
strokeWidth={4}
|
|
||||||
showDistance={false}
|
|
||||||
// zIndex={50}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{polygonCoordinates.length > 0 && (
|
|
||||||
<>
|
|
||||||
{polygonCoordinates.map((polygon, index) => {
|
|
||||||
return (
|
|
||||||
<PolygonWithLabel
|
|
||||||
key={`polygon-${index}-${gpsData?.lat || 0}-${
|
|
||||||
gpsData?.lon || 0
|
|
||||||
}`}
|
|
||||||
coordinates={polygon.coordinates}
|
|
||||||
label={polygon.label}
|
|
||||||
content={polygon.content}
|
|
||||||
fillColor="rgba(16, 85, 201, 0.6)"
|
|
||||||
strokeColor="rgba(16, 85, 201, 0.8)"
|
|
||||||
strokeWidth={2}
|
|
||||||
// zIndex={50}
|
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
|
||||||
</>
|
// Tạo unique key dựa trên thing.id hoặc tọa độ
|
||||||
)} */}
|
const uniqueKey = thing.id
|
||||||
{/* {gpsData !== null && (
|
? `marker-${thing.id}-${index}`
|
||||||
|
: `marker-${gpsData.lat.toFixed(6)}-${gpsData.lon.toFixed(
|
||||||
|
6
|
||||||
|
)}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={
|
key={uniqueKey}
|
||||||
platform === IOS_PLATFORM
|
|
||||||
? `${gpsData.lat}-${gpsData.lon}`
|
|
||||||
: "gps-data"
|
|
||||||
}
|
|
||||||
coordinate={{
|
coordinate={{
|
||||||
latitude: gpsData.lat,
|
latitude: gpsData.lat,
|
||||||
longitude: gpsData.lon,
|
longitude: gpsData.lon,
|
||||||
}}
|
}}
|
||||||
zIndex={20}
|
zIndex={50}
|
||||||
anchor={
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
platform === IOS_PLATFORM
|
// Chỉ tracks changes khi cần thiết
|
||||||
? { x: 0.5, y: 0.5 }
|
tracksViewChanges={
|
||||||
: { x: 0.6, y: 0.4 }
|
platform === IOS_PLATFORM ? tracksViewChanges : true
|
||||||
}
|
}
|
||||||
tracksViewChanges={platform === IOS_PLATFORM ? true : undefined}
|
// Thêm identifier để iOS optimize
|
||||||
|
identifier={uniqueKey}
|
||||||
>
|
>
|
||||||
<View className="w-8 h-8 items-center justify-center">
|
<View className="w-8 h-8 items-center justify-center">
|
||||||
<View style={styles.pingContainer}>
|
<View style={styles.pingContainer}>
|
||||||
{alarmData?.level === 3 && (
|
{thing.metadata?.state_level === 3 && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.pingCircle,
|
styles.pingCircle,
|
||||||
@@ -370,14 +377,16 @@ export default function HomeScreen() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RNImage
|
<Image
|
||||||
source={(() => {
|
source={(() => {
|
||||||
const icon = getShipIcon(
|
const icon = getShipIcon(
|
||||||
alarmData?.level || 0,
|
thing.metadata?.state_level || 0,
|
||||||
gpsData.fishing
|
gpsData.fishing
|
||||||
);
|
);
|
||||||
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
|
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
|
||||||
return typeof icon === "string" ? { uri: icon } : icon;
|
return typeof icon === "string"
|
||||||
|
? { uri: icon }
|
||||||
|
: icon;
|
||||||
})()}
|
})()}
|
||||||
style={{
|
style={{
|
||||||
width: 32,
|
width: 32,
|
||||||
@@ -385,7 +394,8 @@ export default function HomeScreen() {
|
|||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
rotate: `${
|
rotate: `${
|
||||||
typeof gpsData.h === "number" && !isNaN(gpsData.h)
|
typeof gpsData.h === "number" &&
|
||||||
|
!isNaN(gpsData.h)
|
||||||
? gpsData.h
|
? gpsData.h
|
||||||
: 0
|
: 0
|
||||||
}deg`,
|
}deg`,
|
||||||
@@ -396,14 +406,87 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Marker>
|
</Marker>
|
||||||
)} */}
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
|
|
||||||
{/* <View className="absolute top-14 right-2 shadow-md">
|
{/* <View className="absolute top-14 right-2 shadow-md">
|
||||||
<SosButton />
|
<SosButton />
|
||||||
</View>
|
</View>
|
||||||
<GPSInfoPanel gpsData={gpsData!} /> */}
|
<GPSInfoPanel gpsData={gpsData!} /> */}
|
||||||
|
|
||||||
|
{/* Draggable Panel */}
|
||||||
|
<DraggablePanel
|
||||||
|
minHeightPct={0.1}
|
||||||
|
maxHeightPct={0.5}
|
||||||
|
initialState="min"
|
||||||
|
onExpandedChange={(expanded) => {
|
||||||
|
console.log("Panel expanded:", expanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row gap-4 items-center justify-center">
|
||||||
|
<TagState
|
||||||
|
normalCount={things?.metadata?.total_state_level_0 || 0}
|
||||||
|
warningCount={things?.metadata?.total_state_level_1 || 0}
|
||||||
|
dangerousCount={things?.metadata?.total_state_level_2 || 0}
|
||||||
|
sosCount={things?.metadata?.total_sos || 0}
|
||||||
|
disconnectedCount={
|
||||||
|
(things?.metadata?.total_thing ?? 0) -
|
||||||
|
(things?.metadata?.total_connected ?? 0) || 0
|
||||||
|
}
|
||||||
|
onTagPress={handleOnPressState}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={{ width: "100%", paddingVertical: 8, flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 20, textAlign: "center", fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
Danh sách tàu thuyền
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ width: "100%", marginTop: 8, flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 24,
|
||||||
|
// backgroundColor: "green",
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{things && things.things && things.things.length > 0 && (
|
||||||
|
<>
|
||||||
|
{things.things.map((thing, index) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={thing.id ?? index}
|
||||||
|
style={{ width: "100%", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<ShipInfo thingMetadata={thing.metadata} />
|
||||||
|
{index < (things.things?.length ?? 0) - 1 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "50%",
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: "#E5E7EB",
|
||||||
|
marginVertical: 8,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
</DraggablePanel>
|
||||||
|
</View>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
|
import ShipSearchForm from "@/components/ShipSearchForm";
|
||||||
|
import { useState } from "react";
|
||||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function warning() {
|
export default function warning() {
|
||||||
|
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.titleText}>Cảnh báo</Text>
|
<Text style={styles.titleText}>Cảnh báo</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<ShipSearchForm
|
||||||
|
isOpen={shipSearchFormOpen}
|
||||||
|
onClose={() => setShipSearchFormOpen(false)}
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import { toastConfig } from "@/config";
|
|||||||
import { setRouterInstance } from "@/config/auth";
|
import { setRouterInstance } from "@/config/auth";
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import { I18nProvider } from "@/hooks/use-i18n";
|
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 Toast from "react-native-toast-message";
|
||||||
import "../global.css";
|
import "../global.css";
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
|||||||
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 { ThemedText } from "@/components/themed-text";
|
||||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
import { View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
|
|
||||||
interface DescriptionProps {
|
interface DescriptionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -13,12 +13,14 @@ export const Description = ({
|
|||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
return (
|
return (
|
||||||
<View className="flex-row gap-2 ">
|
<View className="flex-row gap-2 ">
|
||||||
<ThemedText
|
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
|
||||||
style={{ color: colors.textSecondary, fontSize: 16 }}
|
|
||||||
>
|
|
||||||
{title}:
|
{title}:
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
|
||||||
|
{description || "-"}
|
||||||
|
</ThemedText>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ export const EVENT_ALARM_DATA = "ALARM_DATA_EVENT";
|
|||||||
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
|
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
|
||||||
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
|
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
|
||||||
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
|
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
|
||||||
|
export const EVENT_SEARCH_THINGS = "SEARCH_THINGS_EVENT";
|
||||||
|
|
||||||
// Entity Contants
|
// Entity Contants
|
||||||
export const ENTITY = {
|
export const ENTITY = {
|
||||||
@@ -28,8 +29,14 @@ export const ENTITY = {
|
|||||||
GPS: "50:1",
|
GPS: "50:1",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const STATUS_NORMAL = 0;
|
||||||
|
export const STATUS_WARNING = 1;
|
||||||
|
export const STATUS_DANGEROUS = 2;
|
||||||
|
export const STATUS_SOS = 3;
|
||||||
|
|
||||||
// API Path Constants
|
// API Path Constants
|
||||||
export const API_PATH_LOGIN = "/api/tokens";
|
export const API_PATH_LOGIN = "/api/tokens";
|
||||||
|
export const API_PATH_SEARCH_THINGS = "/api/things/search";
|
||||||
export const API_PATH_ENTITIES = "/api/io/entities";
|
export const API_PATH_ENTITIES = "/api/io/entities";
|
||||||
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
|
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
|
||||||
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
|
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
|
||||||
@@ -44,3 +51,5 @@ export const API_UPDATE_FISHING_LOGS = "/api/sgw/fishingLog";
|
|||||||
export const API_SOS = "/api/sgw/sos";
|
export const API_SOS = "/api/sgw/sos";
|
||||||
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
||||||
export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
||||||
|
export const API_GET_SHIP_TYPES = "/api/sgw/ships/types";
|
||||||
|
export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup";
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { api } from "@/config";
|
|||||||
import {
|
import {
|
||||||
API_GET_ALARMS,
|
API_GET_ALARMS,
|
||||||
API_GET_GPS,
|
API_GET_GPS,
|
||||||
|
API_GET_SHIP_GROUPS,
|
||||||
|
API_GET_SHIP_TYPES,
|
||||||
API_PATH_ENTITIES,
|
API_PATH_ENTITIES,
|
||||||
|
API_PATH_SEARCH_THINGS,
|
||||||
API_PATH_SHIP_TRACK_POINTS,
|
API_PATH_SHIP_TRACK_POINTS,
|
||||||
API_SOS,
|
API_SOS,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
@@ -35,3 +38,15 @@ export async function queryDeleteSos() {
|
|||||||
export async function querySendSosMessage(message: string) {
|
export async function querySendSosMessage(message: string) {
|
||||||
return await api.put<Model.SosRequest>(API_SOS, { message });
|
return await api.put<Model.SosRequest>(API_SOS, { message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function querySearchThings(body: Model.SearchThingBody) {
|
||||||
|
return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryShipTypes() {
|
||||||
|
return await api.get<Model.ShipType[]>(API_GET_SHIP_TYPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryShipGroups() {
|
||||||
|
return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS);
|
||||||
|
}
|
||||||
|
|||||||
96
controller/typings.d.ts
vendored
96
controller/typings.d.ts
vendored
@@ -15,6 +15,7 @@ declare namespace Model {
|
|||||||
s: number;
|
s: number;
|
||||||
h: number;
|
h: number;
|
||||||
fishing: boolean;
|
fishing: boolean;
|
||||||
|
t: number;
|
||||||
}
|
}
|
||||||
interface Alarm {
|
interface Alarm {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -52,7 +53,7 @@ declare namespace Model {
|
|||||||
|
|
||||||
// Banzones
|
// Banzones
|
||||||
// Banzone
|
// Banzone
|
||||||
export interface Zone {
|
interface Zone {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: number;
|
type?: number;
|
||||||
@@ -62,7 +63,7 @@ declare namespace Model {
|
|||||||
geom?: Geom;
|
geom?: Geom;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Condition {
|
interface Condition {
|
||||||
max?: number;
|
max?: number;
|
||||||
min?: number;
|
min?: number;
|
||||||
type?: Type;
|
type?: Type;
|
||||||
@@ -70,12 +71,12 @@ declare namespace Model {
|
|||||||
from?: number;
|
from?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Type {
|
enum Type {
|
||||||
LengthLimit = "length_limit",
|
LengthLimit = "length_limit",
|
||||||
MonthRange = "month_range",
|
MonthRange = "month_range",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Geom {
|
interface Geom {
|
||||||
geom_type?: number;
|
geom_type?: number;
|
||||||
geom_poly?: string;
|
geom_poly?: string;
|
||||||
geom_lines?: string;
|
geom_lines?: string;
|
||||||
@@ -211,4 +212,91 @@ declare namespace Model {
|
|||||||
cites_appendix: any;
|
cites_appendix: any;
|
||||||
vn_law: boolean;
|
vn_law: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seagateway Owner Appp
|
||||||
|
interface SearchThingBody {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
sort?: "asc" | "desc";
|
||||||
|
name?: string;
|
||||||
|
metadata?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponse {
|
||||||
|
total?: number;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
direction?: string;
|
||||||
|
metadata?: ThingsResponseMetadata;
|
||||||
|
things?: Thing[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponseMetadata {
|
||||||
|
total_connected?: number;
|
||||||
|
total_filter?: number;
|
||||||
|
total_sos?: number;
|
||||||
|
total_state_level_0?: number;
|
||||||
|
total_state_level_1?: number;
|
||||||
|
total_state_level_2?: number;
|
||||||
|
total_thing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Thing {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
metadata?: ThingMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingMetadata {
|
||||||
|
address?: string;
|
||||||
|
alarm_list?: string;
|
||||||
|
basename?: string;
|
||||||
|
cfg_channel_id?: string;
|
||||||
|
cfg_id?: string;
|
||||||
|
connected?: boolean;
|
||||||
|
ctrl_channel_id?: string;
|
||||||
|
data_channel_id?: string;
|
||||||
|
enduser?: string;
|
||||||
|
external_id?: string;
|
||||||
|
gps?: string;
|
||||||
|
gps_time?: string;
|
||||||
|
group_id?: string;
|
||||||
|
req_channel_id?: string;
|
||||||
|
ship_group_id?: string;
|
||||||
|
ship_id?: string;
|
||||||
|
ship_length?: string;
|
||||||
|
ship_name?: string;
|
||||||
|
ship_power?: string;
|
||||||
|
ship_reg_number?: string;
|
||||||
|
ship_type?: string;
|
||||||
|
sos?: string;
|
||||||
|
sos_time?: string;
|
||||||
|
state?: string;
|
||||||
|
state_level?: number;
|
||||||
|
state_updated_time?: number;
|
||||||
|
trip_state?: string;
|
||||||
|
type?: string;
|
||||||
|
updated_time?: number;
|
||||||
|
uptime?: number;
|
||||||
|
zone_approaching_alarm_list?: string;
|
||||||
|
zone_entered_alarm_list?: string;
|
||||||
|
zone_fishing_alarm_list?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship
|
||||||
|
interface ShipType {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroup {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
owner_id?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
"longitude": "Longitude",
|
"longitude": "Longitude",
|
||||||
"speed": "Speed",
|
"speed": "Speed",
|
||||||
|
"speed_units": "knots",
|
||||||
"heading": "Heading",
|
"heading": "Heading",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"latitude": "Vĩ độ",
|
"latitude": "Vĩ độ",
|
||||||
"longitude": "Kinh độ",
|
"longitude": "Kinh độ",
|
||||||
"speed": "Tốc độ",
|
"speed": "Tốc độ",
|
||||||
|
"speed_units": "hải lý/giờ",
|
||||||
"heading": "Hướng",
|
"heading": "Hướng",
|
||||||
"offline": "Ngoại tuyến",
|
"offline": "Ngoại tuyến",
|
||||||
"online": "Trực tuyến",
|
"online": "Trực tuyến",
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -41,7 +41,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-aria": "^3.44.0",
|
"react-aria": "^3.44.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.67.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
@@ -13295,9 +13295,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.66.0",
|
"version": "7.67.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.67.0.tgz",
|
||||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
"integrity": "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-aria": "^3.44.0",
|
"react-aria": "^3.44.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.67.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
EVENT_BANZONE_DATA,
|
EVENT_BANZONE_DATA,
|
||||||
EVENT_ENTITY_DATA,
|
EVENT_ENTITY_DATA,
|
||||||
EVENT_GPS_DATA,
|
EVENT_GPS_DATA,
|
||||||
|
EVENT_SEARCH_THINGS,
|
||||||
EVENT_TRACK_POINTS_DATA,
|
EVENT_TRACK_POINTS_DATA,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
import {
|
import {
|
||||||
queryAlarm,
|
queryAlarm,
|
||||||
queryEntities,
|
queryEntities,
|
||||||
queryGpsData,
|
queryGpsData,
|
||||||
|
querySearchThings,
|
||||||
queryTrackPoints,
|
queryTrackPoints,
|
||||||
} from "@/controller/DeviceController";
|
} from "@/controller/DeviceController";
|
||||||
import { queryBanzones } from "@/controller/MapController";
|
import { queryBanzones } from "@/controller/MapController";
|
||||||
@@ -21,12 +23,14 @@ const intervals: {
|
|||||||
entities: ReturnType<typeof setInterval> | null;
|
entities: ReturnType<typeof setInterval> | null;
|
||||||
trackPoints: ReturnType<typeof setInterval> | null;
|
trackPoints: ReturnType<typeof setInterval> | null;
|
||||||
banzones: ReturnType<typeof setInterval> | null;
|
banzones: ReturnType<typeof setInterval> | null;
|
||||||
|
searchThings: ReturnType<typeof setInterval> | null;
|
||||||
} = {
|
} = {
|
||||||
gps: null,
|
gps: null,
|
||||||
alarm: null,
|
alarm: null,
|
||||||
entities: null,
|
entities: null,
|
||||||
trackPoints: null,
|
trackPoints: null,
|
||||||
banzones: null,
|
banzones: null,
|
||||||
|
searchThings: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getGpsEventBus() {
|
export function getGpsEventBus() {
|
||||||
@@ -153,6 +157,37 @@ export function getBanzonesEventBus() {
|
|||||||
}, AUTO_REFRESH_INTERVAL * 60);
|
}, AUTO_REFRESH_INTERVAL * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchThingEventBus(body: Model.SearchThingBody) {
|
||||||
|
// Clear interval cũ nếu có
|
||||||
|
if (intervals.searchThings) {
|
||||||
|
clearInterval(intervals.searchThings);
|
||||||
|
intervals.searchThings = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchThingsData = async () => {
|
||||||
|
// console.log("call api with body:", body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await querySearchThings(body);
|
||||||
|
if (resp && resp.data) {
|
||||||
|
eventBus.emit(EVENT_SEARCH_THINGS, resp.data);
|
||||||
|
} else {
|
||||||
|
console.log("SearchThings: no data returned");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SearchThings: fetch error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gọi ngay lần đầu
|
||||||
|
searchThingsData();
|
||||||
|
|
||||||
|
// Sau đó setup interval để gọi lại mỗi 30s
|
||||||
|
intervals.searchThings = setInterval(() => {
|
||||||
|
searchThingsData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL * 6); // 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
export function stopEvents() {
|
export function stopEvents() {
|
||||||
Object.keys(intervals).forEach((k) => {
|
Object.keys(intervals).forEach((k) => {
|
||||||
const key = k as keyof typeof intervals;
|
const key = k as keyof typeof intervals;
|
||||||
|
|||||||
43
services/time_service.tsx
Normal file
43
services/time_service.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import "dayjs/locale/vi";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale("vi");
|
||||||
|
|
||||||
|
export { dayjs };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chuyển đổi unix timestamp thành text thời gian tương đối
|
||||||
|
* @param unixTime - Unix timestamp (seconds hoặc milliseconds)
|
||||||
|
* @returns String mô tả thời gian tương đối (vd: "5 phút trước", "2 giờ trước")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(unixTime: number): string {
|
||||||
|
if (!unixTime || unixTime <= 0) return "Không rõ";
|
||||||
|
|
||||||
|
// Xác định đơn vị timestamp (seconds hoặc milliseconds)
|
||||||
|
const timestamp = unixTime < 10000000000 ? unixTime * 1000 : unixTime;
|
||||||
|
|
||||||
|
const updateDate = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - updateDate.getTime();
|
||||||
|
|
||||||
|
// Nếu thời gian trong tương lai
|
||||||
|
if (diffMs < 0) return "Vừa xong";
|
||||||
|
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000);
|
||||||
|
const diffMins = Math.floor(diffSeconds / 60);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
const diffWeeks = Math.floor(diffDays / 7);
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
const diffYears = Math.floor(diffDays / 365);
|
||||||
|
|
||||||
|
if (diffSeconds < 60) return "Vừa xong";
|
||||||
|
if (diffMins < 60) return `${diffMins} phút trước`;
|
||||||
|
if (diffHours < 24) return `${diffHours} giờ trước`;
|
||||||
|
if (diffDays < 7) return `${diffDays} ngày trước`;
|
||||||
|
if (diffWeeks < 4) return `${diffWeeks} tuần trước`;
|
||||||
|
if (diffMonths < 12) return `${diffMonths} tháng trước`;
|
||||||
|
return `${diffYears} năm trước`;
|
||||||
|
}
|
||||||
24
state/use-ship-types.ts
Normal file
24
state/use-ship-types.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { queryShipTypes } from "@/controller/DeviceController";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type ShipType = {
|
||||||
|
shipTypes: Model.ShipType[] | [];
|
||||||
|
getShipTypes: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useShipTypes = create<ShipType>((set) => ({
|
||||||
|
shipTypes: [],
|
||||||
|
getShipTypes: async () => {
|
||||||
|
try {
|
||||||
|
const response = await queryShipTypes();
|
||||||
|
set({ shipTypes: response.data, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when fetch shipTypes: ", error);
|
||||||
|
set({ error: "Failed to fetch shipTypes data", loading: false });
|
||||||
|
set({ shipTypes: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user