Hiển thị tọa độ và khu vực khi tàu vi phạm
This commit is contained in:
@@ -1,46 +1,69 @@
|
||||
import DraggablePanel from "@/components/DraggablePanel";
|
||||
import IconButton from "@/components/IconButton";
|
||||
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
||||
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
||||
import AlarmList from "@/components/map/AlarmList";
|
||||
import ShipInfo from "@/components/map/ShipInfo";
|
||||
import { TagState, TagStateCallbackPayload } from "@/components/map/TagState";
|
||||
import ZoneInMap from "@/components/map/ZoneInMap";
|
||||
import ShipSearchForm, {
|
||||
SearchShipResponse,
|
||||
} from "@/components/ShipSearchForm";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
|
||||
import { queryBanzoneById } from "@/controller/MapController";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { searchThingEventBus } from "@/services/device_events";
|
||||
import { getShipIcon } from "@/services/map_service";
|
||||
import eventBus from "@/utils/eventBus";
|
||||
import { AntDesign } from "@expo/vector-icons";
|
||||
import { AntDesign, Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import MapView, { Marker } from "react-native-maps";
|
||||
|
||||
interface ZoneDataParsed {
|
||||
zone_type?: number;
|
||||
zone_name?: string;
|
||||
zone_id?: string;
|
||||
message?: string;
|
||||
alarm_type?: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
s?: number;
|
||||
h?: number;
|
||||
fishing?: boolean;
|
||||
gps_time?: number;
|
||||
}
|
||||
export interface AlarmData {
|
||||
thing_id: string;
|
||||
ship_name?: string;
|
||||
zone: ZoneDataParsed;
|
||||
type: "approaching" | "entered" | "fishing";
|
||||
}
|
||||
|
||||
export interface BanzoneWithAlarm {
|
||||
alarms: AlarmData;
|
||||
zone?: Model.Zone;
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
||||
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
||||
const [trackPointsData, setTrackPointsData] = useState<
|
||||
Model.ShipTrackPoint[] | null
|
||||
>(null);
|
||||
const [circleRadius, setCircleRadius] = useState(100);
|
||||
const [zoomLevel, setZoomLevel] = useState(10);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
const [alarms, setAlarms] = useState<AlarmData[]>([]);
|
||||
const [banzoneWithAlarm, setBanzoneWithAlarm] =
|
||||
useState<BanzoneWithAlarm | null>(null);
|
||||
const [allBanZones, setAllBanZones] = useState<BanzoneWithAlarm[]>([]);
|
||||
const [showAllAlarmsOnMap, setShowAllAlarmsOnMap] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
||||
PolylineWithLabelProps[]
|
||||
>([]);
|
||||
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||
PolygonWithLabelProps[]
|
||||
>([]);
|
||||
|
||||
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false);
|
||||
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
|
||||
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
|
||||
@@ -54,15 +77,36 @@ export default function HomeScreen() {
|
||||
const [tagStatePayload, setTagStatePayload] =
|
||||
useState<TagStateCallbackPayload | null>(null);
|
||||
|
||||
const [isShowAlarmList, setIsShowAlarmList] = useState(false);
|
||||
// Control mount so we can animate close before unmounting
|
||||
const [isAlarmListMounted, setIsAlarmListMounted] = useState(false);
|
||||
// Thêm state để quản lý tracksViewChanges
|
||||
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
||||
|
||||
// Alarm list animation
|
||||
const screenHeight = Dimensions.get("window").height;
|
||||
const alarmListHeight = Math.round(screenHeight * 0.3);
|
||||
const alarmTranslateY = useRef(new Animated.Value(alarmListHeight)).current;
|
||||
const alarmOpacity = useRef(new Animated.Value(0)).current;
|
||||
const uiAnim = useRef(new Animated.Value(1)).current; // 1 visible, 0 hidden
|
||||
|
||||
useEffect(() => {
|
||||
if (tagStatePayload) {
|
||||
searchThings();
|
||||
}
|
||||
}, [tagStatePayload]);
|
||||
|
||||
const openAlarmList = () => {
|
||||
setIsAlarmListMounted(true);
|
||||
setIsShowAlarmList(true);
|
||||
};
|
||||
|
||||
const closeAlarmList = () => {
|
||||
// Trigger the animation; the effect will unmount when complete
|
||||
setIsShowAlarmList(false);
|
||||
setBanzoneWithAlarm(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shipSearchFormData) {
|
||||
searchThings();
|
||||
@@ -93,7 +137,7 @@ export default function HomeScreen() {
|
||||
...thingsData,
|
||||
things: sortedThings,
|
||||
};
|
||||
console.log("Things Updated: ", sortedThingsResponse.things?.length);
|
||||
// console.log("Things Updated: ", sortedThingsResponse.things?.length);
|
||||
|
||||
setThings(sortedThingsResponse);
|
||||
};
|
||||
@@ -105,101 +149,85 @@ export default function HomeScreen() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (things) {
|
||||
// console.log("Things Updated: ", things.things?.length);
|
||||
// const gpsDatas: Model.GPSResponse[] = [];
|
||||
// for (const thing of things.things || []) {
|
||||
// if (thing.metadata?.gps) {
|
||||
// const gps: Model.GPSResponse = JSON.parse(thing.metadata.gps);
|
||||
// gpsDatas.push(gps);
|
||||
// }
|
||||
// }
|
||||
// console.log("GPS Lenght: ", gpsDatas.length);
|
||||
// setGpsData(gpsDatas);
|
||||
if (things?.things) {
|
||||
const alarmTypes = [
|
||||
{
|
||||
key: "zone_approaching_alarm_list",
|
||||
type: "approaching" as const,
|
||||
},
|
||||
{ key: "zone_entered_alarm_list", type: "entered" as const },
|
||||
{ key: "zone_fishing_alarm_list", type: "fishing" as const },
|
||||
];
|
||||
|
||||
const newAlarms: AlarmData[] = [];
|
||||
|
||||
for (const thing of things.things) {
|
||||
for (const { key, type } of alarmTypes) {
|
||||
if ((thing.metadata as any)?.[key] != "[]") {
|
||||
const zoneList: ZoneDataParsed[] = JSON.parse(
|
||||
(thing?.metadata as any)?.[key] || "[]"
|
||||
);
|
||||
for (const zone of zoneList) {
|
||||
const alarmData: AlarmData = {
|
||||
thing_id: thing.id || "",
|
||||
ship_name: thing.metadata?.ship_name,
|
||||
zone: zone,
|
||||
type: type,
|
||||
};
|
||||
newAlarms.push(alarmData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update alarms, removing old ones not in newAlarms
|
||||
setAlarms((prev) => {
|
||||
const toKeep = prev.filter((a) =>
|
||||
newAlarms.some(
|
||||
(na) =>
|
||||
na.thing_id === a.thing_id && na.zone.zone_id === a.zone.zone_id
|
||||
)
|
||||
);
|
||||
const toAdd = newAlarms.filter(
|
||||
(na) =>
|
||||
!prev.some(
|
||||
(a) =>
|
||||
a.thing_id === na.thing_id && a.zone.zone_id === a.zone.zone_id
|
||||
)
|
||||
);
|
||||
return [...toKeep, ...toAdd];
|
||||
});
|
||||
}
|
||||
}, [things]);
|
||||
|
||||
// useEffect(() => {
|
||||
// setPolylineCoordinates([]);
|
||||
// setPolygonCoordinates([]);
|
||||
// if (!entityData) return;
|
||||
// if (!banzoneData) return;
|
||||
// for (const entity of entityData) {
|
||||
// if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
||||
// continue;
|
||||
// }
|
||||
useEffect(() => {
|
||||
const approaching = alarms.filter((a) => a.type === "approaching");
|
||||
const entered = alarms.filter((a) => a.type === "entered");
|
||||
const fishing = alarms.filter((a) => a.type === "fishing");
|
||||
|
||||
// let zones: any[] = [];
|
||||
// try {
|
||||
// zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
||||
// } catch (parseError) {
|
||||
// console.error("Error parsing zone list:", parseError);
|
||||
// continue;
|
||||
// }
|
||||
// // Nếu danh sách zone rỗng, clear tất cả
|
||||
// if (zones.length === 0) {
|
||||
// setPolylineCoordinates([]);
|
||||
// setPolygonCoordinates([]);
|
||||
// return;
|
||||
// }
|
||||
if (approaching.length > 0) {
|
||||
console.log("ZoneApproachingAlarm: ", approaching);
|
||||
} else {
|
||||
// console.log("No ZoneApproachingAlarm");
|
||||
}
|
||||
if (entered.length > 0) {
|
||||
console.log("ZoneEnteredAlarm: ", entered);
|
||||
} else {
|
||||
// console.log("No ZoneEnteredAlarm");
|
||||
}
|
||||
if (fishing.length > 0) {
|
||||
console.log("ZoneFishingAlarm: ", fishing);
|
||||
} else {
|
||||
// console.log("No ZoneFishingAlarm");
|
||||
}
|
||||
}, [alarms]);
|
||||
|
||||
// let polylines: PolylineWithLabelProps[] = [];
|
||||
// let polygons: PolygonWithLabelProps[] = [];
|
||||
|
||||
// for (const zone of zones) {
|
||||
// // console.log("Zone Data: ", zone);
|
||||
// const geom = banzoneData.find((b) => b.id === zone.zone_id);
|
||||
// if (!geom) {
|
||||
// continue;
|
||||
// }
|
||||
// const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||
// if (typeof geom_type !== "number") {
|
||||
// continue;
|
||||
// }
|
||||
// if (geom_type === 2) {
|
||||
// // if(oldEntityData.find(e => e.id === ))
|
||||
// // foundPolyline = true;
|
||||
// const coordinates = convertWKTLineStringToLatLngArray(
|
||||
// geom_lines || ""
|
||||
// );
|
||||
// if (coordinates.length > 0) {
|
||||
// polylines.push({
|
||||
// coordinates: coordinates.map((coord) => ({
|
||||
// latitude: coord[0],
|
||||
// longitude: coord[1],
|
||||
// })),
|
||||
// label: zone?.zone_name ?? "",
|
||||
// content: zone?.message ?? "",
|
||||
// });
|
||||
// } else {
|
||||
// console.log("Không tìm thấy polyline trong alarm");
|
||||
// }
|
||||
// } else if (geom_type === 1) {
|
||||
// // foundPolygon = true;
|
||||
// const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
||||
// if (coordinates.length > 0) {
|
||||
// // console.log("Polygon Coordinate: ", coordinates);
|
||||
// const zonePolygons = coordinates.map((polygon) => ({
|
||||
// coordinates: polygon.map((coord) => ({
|
||||
// latitude: coord[0],
|
||||
// longitude: coord[1],
|
||||
// })),
|
||||
// label: zone?.zone_name ?? "",
|
||||
// content: zone?.message ?? "",
|
||||
// }));
|
||||
// polygons.push(...zonePolygons);
|
||||
// } else {
|
||||
// console.log("Không tìm thấy polygon trong alarm");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// setPolylineCoordinates(polylines);
|
||||
// setPolygonCoordinates(polygons);
|
||||
// }
|
||||
// }, [banzoneData, entityData]);
|
||||
|
||||
// Hàm tính radius cố định khi zoom change
|
||||
// Load all banzones when alarms change (for showing all alarms on map)
|
||||
useEffect(() => {
|
||||
if (alarms.length > 0 && showAllAlarmsOnMap) {
|
||||
loadAllBanZones();
|
||||
}
|
||||
}, [alarms, showAllAlarmsOnMap]);
|
||||
|
||||
const calculateRadiusFromZoom = (zoom: number) => {
|
||||
const baseZoom = 10;
|
||||
@@ -217,8 +245,8 @@ export default function HomeScreen() {
|
||||
// zoom = log2(360 / (latitudeDelta * 2)) + 8
|
||||
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
|
||||
const newRadius = calculateRadiusFromZoom(zoom);
|
||||
setCircleRadius(newRadius);
|
||||
setZoomLevel(zoom);
|
||||
// setCircleRadius(newRadius);
|
||||
// setZoomLevel(zoom);
|
||||
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
|
||||
};
|
||||
|
||||
@@ -303,8 +331,53 @@ export default function HomeScreen() {
|
||||
}
|
||||
}, [isFirstLoad]);
|
||||
|
||||
// Animate alarm panel when isShowAlarmList changes
|
||||
// Keep the overlay mounted while animating it in/out.
|
||||
useEffect(() => {
|
||||
if (isShowAlarmList) {
|
||||
// Ensure mounted then animate to visible
|
||||
setIsAlarmListMounted(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(alarmTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(alarmOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
// Animate out, then unmount
|
||||
Animated.parallel([
|
||||
Animated.timing(alarmTranslateY, {
|
||||
toValue: alarmListHeight,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(alarmOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setIsAlarmListMounted(false);
|
||||
});
|
||||
}
|
||||
}, [alarmTranslateY, alarmListHeight, alarmOpacity, isShowAlarmList]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(uiAnim, {
|
||||
toValue: isAlarmListMounted ? 0 : 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [uiAnim, isAlarmListMounted]);
|
||||
|
||||
const searchThings = async () => {
|
||||
console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
|
||||
// console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
|
||||
|
||||
// Xây dựng query state dựa trên logic bạn cung cấp
|
||||
const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : "";
|
||||
@@ -391,7 +464,7 @@ export default function HomeScreen() {
|
||||
not_empty: "ship_id",
|
||||
},
|
||||
};
|
||||
console.log("Search Params: ", searchParams);
|
||||
// console.log("Search Params: ", searchParams);
|
||||
|
||||
// Gọi API tìm kiếm
|
||||
searchThingEventBus(searchParams);
|
||||
@@ -401,6 +474,50 @@ export default function HomeScreen() {
|
||||
setShipSearchFormOpen(false);
|
||||
};
|
||||
|
||||
const loadAllBanZones = async () => {
|
||||
try {
|
||||
const banzonePromises = alarms.map(async (alarm) => {
|
||||
try {
|
||||
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
|
||||
return {
|
||||
alarms: alarm,
|
||||
zone: banzone.data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Cannot get banzone for zone_id: ${alarm.zone.zone_id}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const banzoneResults = await Promise.all(banzonePromises);
|
||||
const validBanZones = banzoneResults.filter(
|
||||
(banzone): banzone is NonNullable<typeof banzone> => banzone !== null
|
||||
);
|
||||
setAllBanZones(validBanZones);
|
||||
} catch (error) {
|
||||
console.error("Error loading all banzones:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlarmPress = async (alarm: AlarmData) => {
|
||||
console.log("Alarm pressed from list:", alarm);
|
||||
try {
|
||||
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
|
||||
// console.log("Banzone API response:", banzone);
|
||||
// console.log("Banzone data:", banzone.data);
|
||||
// console.log("Zone geometry:", banzone.data?.geometry);
|
||||
const banzoneWithAlarm: BanzoneWithAlarm = {
|
||||
alarms: alarm,
|
||||
zone: banzone.data,
|
||||
};
|
||||
|
||||
setBanzoneWithAlarm(banzoneWithAlarm);
|
||||
setShowAllAlarmsOnMap(false);
|
||||
} catch (error) {
|
||||
console.error("Cannot get Banzone:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = shipSearchFormData
|
||||
? shipSearchFormData.ship_name !== "" ||
|
||||
shipSearchFormData.reg_number !== "" ||
|
||||
@@ -418,6 +535,7 @@ export default function HomeScreen() {
|
||||
<GestureHandlerRootView style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
onMapReady={handleMapReady}
|
||||
onRegionChangeComplete={handleRegionChangeComplete}
|
||||
style={styles.map}
|
||||
@@ -428,8 +546,9 @@ export default function HomeScreen() {
|
||||
loadingEnabled={true}
|
||||
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
||||
rotateEnabled={false}
|
||||
// onMarkerPress={onMarkerPress}
|
||||
>
|
||||
{things?.things && things.things.length > 0 && (
|
||||
{!banzoneWithAlarm && things?.things && things.things.length > 0 && (
|
||||
<>
|
||||
{things.things
|
||||
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
|
||||
@@ -454,6 +573,10 @@ export default function HomeScreen() {
|
||||
}}
|
||||
zIndex={50}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
title={thing.metadata?.ship_name}
|
||||
description={`Trạng thái: ${
|
||||
gpsData.fishing ? "Đang đánh bắt" : "Không đánh bắt"
|
||||
}`}
|
||||
// Chỉ tracks changes khi cần thiết
|
||||
tracksViewChanges={
|
||||
platform === IOS_PLATFORM ? tracksViewChanges : true
|
||||
@@ -461,6 +584,7 @@ export default function HomeScreen() {
|
||||
// Thêm identifier để iOS optimize
|
||||
identifier={uniqueKey}
|
||||
>
|
||||
{/* <Callout tooltip></Callout> */}
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<View style={styles.pingContainer}>
|
||||
{thing.metadata?.state_level === 3 && (
|
||||
@@ -507,9 +631,16 @@ export default function HomeScreen() {
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{(banzoneWithAlarm ||
|
||||
(showAllAlarmsOnMap && allBanZones.length > 0)) && (
|
||||
<ZoneInMap
|
||||
banzones={banzoneWithAlarm ? [banzoneWithAlarm] : allBanZones}
|
||||
mapRef={mapRef}
|
||||
/>
|
||||
)}
|
||||
</MapView>
|
||||
<View className="absolute top-20 left-5">
|
||||
{!isPanelExpanded && (
|
||||
<View className="absolute top-12 left-5">
|
||||
{!isAlarmListMounted && !isPanelExpanded && (
|
||||
<IconButton
|
||||
icon={<AntDesign name="filter" size={16} />}
|
||||
type="primary"
|
||||
@@ -528,102 +659,158 @@ export default function HomeScreen() {
|
||||
<GPSInfoPanel gpsData={gpsData!} /> */}
|
||||
|
||||
{/* Draggable Panel */}
|
||||
<DraggablePanel
|
||||
minHeightPct={0.1}
|
||||
maxHeightPct={0.6}
|
||||
initialState="min"
|
||||
onExpandedChange={(expanded) => {
|
||||
console.log("Panel expanded:", expanded);
|
||||
setIsPanelExpanded(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={(tagState) => {
|
||||
setTagStatePayload(tagState);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width: "100%", paddingVertical: 8, flex: 1 }}>
|
||||
<View className="flex flex-row items-center">
|
||||
<View className="flex-1 justify-center">
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
textAlign: "center",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Danh sách tàu thuyền
|
||||
</Text>
|
||||
</View>
|
||||
{isPanelExpanded && (
|
||||
<IconButton
|
||||
icon={<AntDesign name="filter" size={16} />}
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="middle"
|
||||
style={{
|
||||
// borderRadius: 10,
|
||||
backgroundColor: hasActiveFilters ? "#B8D576" : "#fff",
|
||||
}}
|
||||
onPress={() => setShipSearchFormOpen(true)}
|
||||
></IconButton>
|
||||
)}
|
||||
{!isAlarmListMounted && (
|
||||
<DraggablePanel
|
||||
minHeightPct={0.1}
|
||||
maxHeightPct={0.6}
|
||||
initialState="min"
|
||||
onExpandedChange={(expanded) => {
|
||||
// console.log("Panel expanded:", expanded);
|
||||
setIsPanelExpanded(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={(tagState) => {
|
||||
setTagStatePayload(tagState);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width: "100%", paddingVertical: 8, flex: 1 }}>
|
||||
<View className="flex flex-row items-center">
|
||||
<View className="flex-1 justify-center">
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
textAlign: "center",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Danh sách tàu thuyền
|
||||
</Text>
|
||||
</View>
|
||||
{isPanelExpanded && (
|
||||
<IconButton
|
||||
icon={<AntDesign name="filter" size={16} />}
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="middle"
|
||||
style={{
|
||||
// borderRadius: 10,
|
||||
backgroundColor: hasActiveFilters ? "#B8D576" : "#fff",
|
||||
}}
|
||||
onPress={() => setShipSearchFormOpen(true)}
|
||||
></IconButton>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={{ width: "100%", marginTop: 8, flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
alignItems: "center",
|
||||
paddingBottom: 24,
|
||||
<ScrollView
|
||||
style={{ width: "100%", marginTop: 8, flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
alignItems: "center",
|
||||
paddingBottom: 24,
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
{/* Alarm list overlay */}
|
||||
{isAlarmListMounted && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: alarmListHeight,
|
||||
transform: [{ translateY: alarmTranslateY }],
|
||||
opacity: alarmOpacity,
|
||||
elevation: 10,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<View className="bg-white rounded-t-3xl shadow-md overflow-hidden h-full z-50">
|
||||
<View className="flex-row items-center justify-between px-4 py-2">
|
||||
<Text className="text-lg font-semibold">
|
||||
Danh sách cảnh báo
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => closeAlarmList()}
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="#374151" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#F9FAFB",
|
||||
zIndex: 50,
|
||||
}}
|
||||
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>
|
||||
{/* <ThemedText className="text-lg font-semibold">Body</ThemedText> */}
|
||||
<AlarmList data={alarms} onPress={handleAlarmPress} />
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</DraggablePanel>
|
||||
</Animated.View>
|
||||
)}
|
||||
<ShipSearchForm
|
||||
initialValues={shipSearchFormData}
|
||||
isOpen={shipSearchFormOpen}
|
||||
onClose={() => setShipSearchFormOpen(false)}
|
||||
onSubmit={handleOnSubmitSearchForm}
|
||||
/>
|
||||
{!isAlarmListMounted && alarms.length > 0 && (
|
||||
<View className="absolute top-12 right-5 space-y-2">
|
||||
<IconButton
|
||||
icon={<Ionicons name="warning" size={16} color="#fff" />}
|
||||
type="danger"
|
||||
size="middle"
|
||||
onPress={() => openAlarmList()}
|
||||
>
|
||||
<ThemedText className="text-sm font-semibold">
|
||||
{alarms.length}
|
||||
</ThemedText>
|
||||
</IconButton>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,323 @@
|
||||
import ShipSearchForm from "@/components/ShipSearchForm";
|
||||
import { useState } from "react";
|
||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FlatList, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AlarmData } from ".";
|
||||
|
||||
// ============ Types ============
|
||||
type AlarmType = "approaching" | "entered" | "fishing";
|
||||
|
||||
interface AlarmCardProps {
|
||||
alarm: AlarmData;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
// ============ Config ============
|
||||
const ALARM_CONFIG: Record<
|
||||
AlarmType,
|
||||
{
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
iconBgColor: string;
|
||||
iconColor: string;
|
||||
labelColor: string;
|
||||
}
|
||||
> = {
|
||||
entered: {
|
||||
icon: "warning",
|
||||
label: "Xâm nhập",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconBgColor: "bg-red-100",
|
||||
iconColor: "#DC2626",
|
||||
labelColor: "text-red-600",
|
||||
},
|
||||
approaching: {
|
||||
icon: "alert-circle",
|
||||
label: "Tiếp cận",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
iconBgColor: "bg-amber-100",
|
||||
iconColor: "#D97706",
|
||||
labelColor: "text-amber-600",
|
||||
},
|
||||
fishing: {
|
||||
icon: "fish",
|
||||
label: "Đánh bắt",
|
||||
bgColor: "bg-orange-50",
|
||||
borderColor: "border-orange-200",
|
||||
iconBgColor: "bg-orange-100",
|
||||
iconColor: "#EA580C",
|
||||
labelColor: "text-orange-600",
|
||||
},
|
||||
};
|
||||
|
||||
// ============ Helper Functions ============
|
||||
const formatTimestamp = (timestamp?: number): string => {
|
||||
if (!timestamp) return "N/A";
|
||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||
};
|
||||
|
||||
// ============ AlarmCard Component ============
|
||||
const AlarmCard = React.memo(({ alarm, onPress }: AlarmCardProps) => {
|
||||
const config = ALARM_CONFIG[alarm.type];
|
||||
|
||||
export default function warning() {
|
||||
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(true);
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.titleText}>Cảnh báo</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
||||
>
|
||||
<View className="flex-row items-start gap-3">
|
||||
{/* Icon Container */}
|
||||
<View
|
||||
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||
</View>
|
||||
<ShipSearchForm
|
||||
isOpen={shipSearchFormOpen}
|
||||
onClose={() => setShipSearchFormOpen(false)}
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1">
|
||||
{/* Header: Ship name + Badge */}
|
||||
<View className="flex-row items-center justify-between mb-1">
|
||||
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
|
||||
{alarm.ship_name || alarm.thing_id}
|
||||
</ThemedText>
|
||||
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
||||
<ThemedText
|
||||
className={`text-xs font-semibold ${config.labelColor}`}
|
||||
>
|
||||
{config.label}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Zone Info */}
|
||||
<ThemedText className="text-sm text-gray-600 mb-2" numberOfLines={2}>
|
||||
{alarm.zone.message || alarm.zone.zone_name}
|
||||
</ThemedText>
|
||||
|
||||
{/* Footer: Zone ID + Time */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
||||
<ThemedText className="text-xs text-gray-500">
|
||||
{formatTimestamp(alarm.zone.gps_time)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
AlarmCard.displayName = "AlarmCard";
|
||||
|
||||
// ============ Main Component ============
|
||||
interface WarningScreenProps {
|
||||
alarms?: AlarmData[];
|
||||
}
|
||||
|
||||
export default function WarningScreen({ alarms = [] }: WarningScreenProps) {
|
||||
// Mock data for demo - replace with actual props
|
||||
const sampleAlarms: AlarmData[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
thing_id: "SHIP-001",
|
||||
ship_name: "Ocean Star",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực cấm A1",
|
||||
zone_id: "A1",
|
||||
message: "Tàu đã đi vào vùng cấm A1",
|
||||
alarm_type: 1,
|
||||
lat: 10.12345,
|
||||
lon: 106.12345,
|
||||
s: 12,
|
||||
h: 180,
|
||||
fishing: false,
|
||||
gps_time: 1733389200,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-002",
|
||||
ship_name: "Blue Whale",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng cảnh báo B3",
|
||||
zone_id: "B3",
|
||||
message: "Tàu đang tiếp cận khu vực cấm B3",
|
||||
alarm_type: 2,
|
||||
lat: 9.87654,
|
||||
lon: 105.87654,
|
||||
gps_time: 1733389260,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-003",
|
||||
ship_name: "Sea Dragon",
|
||||
type: "fishing",
|
||||
zone: {
|
||||
zone_type: 3,
|
||||
zone_name: "Vùng cấm đánh bắt C2",
|
||||
zone_id: "C2",
|
||||
message: "Phát hiện hành vi đánh bắt trong vùng cấm C2",
|
||||
alarm_type: 3,
|
||||
lat: 11.11223,
|
||||
lon: 107.44556,
|
||||
fishing: true,
|
||||
gps_time: 1733389320,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-004",
|
||||
ship_name: "Red Coral",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực A2",
|
||||
zone_id: "A2",
|
||||
message: "Tàu đã đi sâu vào khu vực A2",
|
||||
alarm_type: 1,
|
||||
gps_time: 1733389380,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-005",
|
||||
ship_name: "Silver Wind",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng B1",
|
||||
zone_id: "B1",
|
||||
message: "Tàu đang tiến gần vào vùng B1",
|
||||
alarm_type: 2,
|
||||
gps_time: 1733389440,
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const displayAlarms = alarms.length > 0 ? alarms : sampleAlarms;
|
||||
|
||||
const handleAlarmPress = useCallback((alarm: AlarmData) => {
|
||||
console.log("Alarm pressed:", alarm);
|
||||
// TODO: Navigate to alarm detail or show modal
|
||||
}, []);
|
||||
|
||||
const renderAlarmCard = useCallback(
|
||||
({ item }: { item: AlarmData }) => (
|
||||
<AlarmCard alarm={item} onPress={() => handleAlarmPress(item)} />
|
||||
),
|
||||
[handleAlarmPress]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
||||
[]
|
||||
);
|
||||
|
||||
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
||||
|
||||
// Count alarms by type
|
||||
const alarmCounts = useMemo(() => {
|
||||
return displayAlarms.reduce((acc, alarm) => {
|
||||
acc[alarm.type] = (acc[alarm.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<AlarmType, number>);
|
||||
}, [displayAlarms]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={["top"]}>
|
||||
<ThemedView style={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="w-10 h-10 rounded-xl bg-red-500 items-center justify-center">
|
||||
<Ionicons name="warning" size={22} color="#fff" />
|
||||
</View>
|
||||
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
||||
</View>
|
||||
<View className="bg-red-500 px-3 py-1 rounded-full">
|
||||
<ThemedText className="text-white text-sm font-semibold">
|
||||
{displayAlarms.length}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<View className="flex-row px-4 pb-3 gap-2">
|
||||
{(["entered", "approaching", "fishing"] as AlarmType[]).map(
|
||||
(type) => {
|
||||
const config = ALARM_CONFIG[type];
|
||||
const count = alarmCounts[type] || 0;
|
||||
return (
|
||||
<View
|
||||
key={type}
|
||||
className={`flex-1 flex-row items-center justify-center gap-1 py-2 rounded-lg ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={14}
|
||||
color={config.iconColor}
|
||||
/>
|
||||
<ThemedText
|
||||
className={`text-xs font-medium ${config.labelColor}`}
|
||||
>
|
||||
{count}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Alarm List */}
|
||||
<FlatList
|
||||
data={displayAlarms}
|
||||
renderItem={renderAlarmCard}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
/>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 15,
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 32,
|
||||
fontSize: 26,
|
||||
fontWeight: "700",
|
||||
lineHeight: 40,
|
||||
marginBottom: 30,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user