Files
sgw-owner-app/app/(tabs)/index.tsx

871 lines
28 KiB
TypeScript

import DraggablePanel from "@/components/DraggablePanel";
import IconButton from "@/components/IconButton";
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, 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 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 [shipSearchFormOpen, setShipSearchFormOpen] = useState(false);
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
const platform = usePlatform();
const theme = useThemeContext().colorScheme;
const scale = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
const [shipSearchFormData, setShipSearchFormData] = useState<
SearchShipResponse | undefined
>(undefined);
const [tagStatePayload, setTagStatePayload] =
useState<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();
}
}, [shipSearchFormData]);
const bodySearchThings: Model.SearchThingBody = {
offset: 0,
limit: 50,
order: "name",
dir: "asc",
metadata: {
not_empty: "ship_id",
},
};
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?.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(() => {
const approaching = alarms.filter((a) => a.type === "approaching");
const entered = alarms.filter((a) => a.type === "entered");
const fishing = alarms.filter((a) => a.type === "fishing");
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]);
// 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;
const baseRadius = 100;
const zoomDifference = baseZoom - zoom;
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
// console.log("Caculate Radius: ", calculatedRadius);
return Math.max(calculatedRadius, 50);
};
// Xử lý khi region (zoom) thay đổi
const handleRegionChangeComplete = (newRegion: any) => {
// Tính zoom level từ latitudeDelta
// 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);
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
};
// Tính toán region để focus vào marker của tàu (chỉ lần đầu tiên)
const getMapRegion = () => {
if (!isFirstLoad) {
// Sau lần đầu, return undefined để không force region
return undefined;
}
if (things?.things?.length === 0) {
return {
latitude: 15.70581,
longitude: 116.152685,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
};
}
return {
latitude: things?.things?.[0]?.metadata?.gps
? JSON.parse(things.things[0].metadata.gps).lat
: 15.70581,
longitude: things?.things?.[0]?.metadata?.gps
? JSON.parse(things.things[0].metadata.gps).lon
: 116.152685,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
};
};
const handleMapReady = () => {
setTimeout(() => {
setIsFirstLoad(false);
}, 2000);
};
useEffect(() => {
for (var thing of things?.things || []) {
if (thing.metadata?.state_level === 3) {
const loop = Animated.loop(
Animated.sequence([
Animated.parallel([
Animated.timing(scale, {
toValue: 3, // nở to 3 lần
duration: 1500,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0, // mờ dần
duration: 1500,
useNativeDriver: true,
}),
]),
Animated.parallel([
Animated.timing(scale, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 0,
useNativeDriver: true,
}),
]),
])
);
loop.start();
return () => loop.stop();
}
}
}, [things, scale, opacity]);
// 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]);
// 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);
// Xây dựng query state dựa trên logic bạn cung cấp
const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : "";
const stateSosQuery = tagStatePayload?.isSos ? "sos" : "";
const stateWarningQuery = tagStatePayload?.isWarning
? stateNormalQuery + ",warning"
: stateNormalQuery;
const stateCriticalQuery = tagStatePayload?.isDangerous
? stateWarningQuery + ",critical"
: stateWarningQuery;
// Nếu bật tất cả filter thì không cần truyền stateQuery
const stateQuery =
tagStatePayload?.isNormal &&
tagStatePayload?.isWarning &&
tagStatePayload?.isDangerous &&
tagStatePayload?.isSos
? ""
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(",");
let metaFormQuery = {};
if (tagStatePayload?.isDisconected)
metaFormQuery = { ...metaFormQuery, connected: false };
if (shipSearchFormData?.ship_name) {
metaFormQuery = {
...metaFormQuery,
ship_name: shipSearchFormData?.ship_name,
};
}
if (shipSearchFormData?.reg_number) {
metaFormQuery = {
...metaFormQuery,
reg_number: shipSearchFormData?.reg_number,
};
}
if (
shipSearchFormData?.ship_length[0] !== 0 ||
shipSearchFormData?.ship_length[1] !== 100
) {
metaFormQuery = {
...metaFormQuery,
ship_length: shipSearchFormData?.ship_length,
};
}
if (
shipSearchFormData?.ship_power[0] !== 0 ||
shipSearchFormData?.ship_power[1] !== 100000
) {
metaFormQuery = {
...metaFormQuery,
ship_power: shipSearchFormData?.ship_power,
};
}
if (shipSearchFormData?.alarm_list) {
metaFormQuery = {
...metaFormQuery,
alarm_list: shipSearchFormData?.alarm_list,
};
}
if (shipSearchFormData?.ship_type) {
metaFormQuery = {
...metaFormQuery,
ship_type: shipSearchFormData?.ship_type,
};
}
if (shipSearchFormData?.ship_group_id) {
metaFormQuery = {
...metaFormQuery,
ship_group_id: shipSearchFormData?.ship_group_id,
};
}
// Tạo metadata query
const metaStateQuery =
stateQuery !== "" ? { state_level: stateQuery } : null;
// Tạo body search với filter
const searchParams: Model.SearchThingBody = {
offset: 0,
limit: 50,
order: "name",
dir: "asc",
metadata: {
...metaFormQuery,
...metaStateQuery,
not_empty: "ship_id",
},
};
// console.log("Search Params: ", searchParams);
// Gọi API tìm kiếm
searchThingEventBus(searchParams);
};
const handleOnSubmitSearchForm = async (data: SearchShipResponse) => {
setShipSearchFormData(data);
setShipSearchFormOpen(false);
};
const 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 !== "" ||
shipSearchFormData.ship_length[0] !== 0 ||
shipSearchFormData.ship_length[1] !== 100 ||
shipSearchFormData.ship_power[0] !== 0 ||
shipSearchFormData.ship_power[1] !== 100000 ||
shipSearchFormData.ship_type !== "" ||
shipSearchFormData.alarm_list !== "" ||
shipSearchFormData.ship_group_id !== "" ||
shipSearchFormData.group_id !== ""
: false;
return (
<GestureHandlerRootView style={styles.container}>
<View style={styles.container}>
<MapView
ref={mapRef}
onMapReady={handleMapReady}
onRegionChangeComplete={handleRegionChangeComplete}
style={styles.map}
region={getMapRegion()}
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
showsBuildings={false}
showsIndoors={false}
loadingEnabled={true}
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
rotateEnabled={false}
// onMarkerPress={onMarkerPress}
>
{!banzoneWithAlarm && things?.things && things.things.length > 0 && (
<>
{things.things
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
.map((thing, index) => {
const gpsData: Model.GPSResponse = JSON.parse(
thing.metadata!.gps!
);
// Tạo unique key dựa trên thing.id hoặc tọa độ
const uniqueKey = thing.id
? `marker-${thing.id}-${index}`
: `marker-${gpsData.lat.toFixed(6)}-${gpsData.lon.toFixed(
6
)}-${index}`;
return (
<Marker
key={uniqueKey}
coordinate={{
latitude: gpsData.lat,
longitude: gpsData.lon,
}}
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
}
// 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 && (
<Animated.View
style={[
styles.pingCircle,
{
transform: [{ scale }],
opacity,
},
]}
/>
)}
<Image
source={(() => {
const icon = getShipIcon(
thing.metadata?.state_level || 0,
gpsData.fishing
);
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
return typeof icon === "string"
? { uri: icon }
: icon;
})()}
style={{
width: 32,
height: 32,
transform: [
{
rotate: `${
typeof gpsData.h === "number" &&
!isNaN(gpsData.h)
? gpsData.h
: 0
}deg`,
},
],
}}
/>
</View>
</View>
</Marker>
);
})}
</>
)}
{(banzoneWithAlarm ||
(showAllAlarmsOnMap && allBanZones.length > 0)) && (
<ZoneInMap
banzones={banzoneWithAlarm ? [banzoneWithAlarm] : allBanZones}
mapRef={mapRef}
/>
)}
</MapView>
<View className="absolute top-12 left-5">
{!isAlarmListMounted && !isPanelExpanded && (
<IconButton
icon={<AntDesign name="filter" size={16} />}
type="primary"
size="large"
style={{
borderRadius: 10,
backgroundColor: hasActiveFilters ? "#B8D576" : "#fff",
}}
onPress={() => setShipSearchFormOpen(true)}
></IconButton>
)}
</View>
{/* <View className="absolute top-14 right-2 shadow-md">
<SosButton />
</View>
<GPSInfoPanel gpsData={gpsData!} /> */}
{/* Draggable Panel */}
{!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,
}}
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,
}}
>
{/* <ThemedText className="text-lg font-semibold">Body</ThemedText> */}
<AlarmList data={alarms} onPress={handleAlarmPress} />
</View>
</View>
</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>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
flex: 1,
},
button: {
// display: "none",
position: "absolute",
top: 50,
right: 20,
backgroundColor: "#007AFF",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
elevation: 5,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
pingContainer: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
overflow: "visible",
},
pingCircle: {
position: "absolute",
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ED3F27",
},
centerDot: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "#0096FF",
},
});