Compare commits

..

2 Commits

Author SHA1 Message Date
347bd1a7c1 Gip khi cuộn xuống ( tab diary ) 2025-12-08 10:05:04 +07:00
Tran Anh Tuan
695066a5e7 Hiển thị tọa độ và khu vực khi tàu vi phạm 2025-12-08 09:31:28 +07:00
15 changed files with 1682 additions and 351 deletions

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@@ -29,6 +30,12 @@ export default function diary() {
selectedShip: null, // Tàu được chọn
});
// State for pagination
const [allTrips, setAllTrips] = useState<any[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const isInitialLoad = useRef(true);
// Body call API things (đang fix cứng)
const payloadThings: Model.SearchThingBody = {
offset: 0,
@@ -65,18 +72,48 @@ export default function diary() {
},
});
const { tripsList, getTripsList } = useTripsList();
const { tripsList, getTripsList, loading } = useTripsList();
// console.log("Payload trips:", payloadTrips);
// Gọi API trips lần đầu
useEffect(() => {
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
getTripsList(payloadTrips);
}, []);
// Gọi lại API khi payload thay đổi (do filter)
// Xử lý khi nhận được dữ liệu mới từ API
useEffect(() => {
getTripsList(payloadTrips);
console.log("Payload trips:", payloadTrips);
}, [payloadTrips]);
if (tripsList?.trips && tripsList.trips.length > 0) {
if (isInitialLoad.current || payloadTrips.offset === 0) {
// Load lần đầu hoặc reset do filter
setAllTrips(tripsList.trips);
isInitialLoad.current = false;
} else {
// Load more - append data
setAllTrips((prevTrips) => {
// Tránh duplicate bằng cách check ID
const existingIds = new Set(prevTrips.map((trip) => trip.id));
const newTrips = tripsList.trips!.filter(
(trip) => !existingIds.has(trip.id)
);
return [...prevTrips, ...newTrips];
});
}
// Check if có thêm data không
const totalFetched = payloadTrips.offset + tripsList.trips.length;
setHasMore(totalFetched < (tripsList.total || 0));
setIsLoadingMore(false);
} else if (tripsList && payloadTrips.offset === 0) {
// Trường hợp API trả về rỗng khi filter
setAllTrips([]);
setHasMore(false);
setIsLoadingMore(false);
}
}, [tripsList]);
const handleFilter = () => {
setShowFilterModal(true);
@@ -85,10 +122,11 @@ export default function diary() {
const handleApplyFilters = (newFilters: FilterValues) => {
setFilters(newFilters);
// Cập nhật payload với filter mới
// Cập nhật payload với filter mới và reset offset
// Lưu ý: status gửi lên server là string
const updatedPayload: Model.TripListBody = {
...payloadTrips,
offset: 0, // Reset về đầu khi filter
metadata: {
...payloadTrips.metadata,
from: newFilters.startDate
@@ -104,10 +142,30 @@ export default function diary() {
},
};
// Reset trips khi apply filter mới
setAllTrips([]);
setHasMore(true);
setPayloadTrips(updatedPayload);
getTripsList(updatedPayload);
setShowFilterModal(false);
};
// Hàm load more data khi scroll đến cuối
const handleLoadMore = useCallback(() => {
if (isLoadingMore || !hasMore) {
return;
}
setIsLoadingMore(true);
const newOffset = payloadTrips.offset + payloadTrips.limit;
const updatedPayload = {
...payloadTrips,
offset: newOffset,
};
setPayloadTrips(updatedPayload);
getTripsList(updatedPayload);
}, [isLoadingMore, hasMore, payloadTrips]);
const handleTripPress = (tripId: string) => {
// TODO: Navigate to trip detail
console.log("Trip pressed:", tripId);
@@ -157,6 +215,59 @@ export default function diary() {
},
};
// Render mỗi item trong FlatList
const renderTripItem = useCallback(
({ item }: { item: any }) => (
<TripCard
trip={item}
onPress={() => handleTripPress(item.id)}
onView={() => handleViewTrip(item.id)}
onEdit={() => handleEditTrip(item.id)}
onTeam={() => handleViewTeam(item.id)}
onSend={() => handleSendTrip(item.id)}
onDelete={() => handleDeleteTrip(item.id)}
/>
),
[]
);
// Key extractor cho FlatList
const keyExtractor = useCallback((item: any) => item.id, []);
// Loading indicator khi load more
const renderFooter = () => {
// Không hiển thị loading footer nếu không có dữ liệu hoặc không đang load more
if (!isLoadingMore || allTrips.length === 0) return null;
return (
<View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("diary.loadingMore")}
</Text>
</View>
);
};
// Empty component
const renderEmpty = () => {
// Hiển thị loading khi đang load (lần đầu hoặc load more) và chưa có dữ liệu
if (loading && allTrips.length === 0) {
return (
<View style={styles.emptyState}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
// Chỉ hiển thị "không có dữ liệu" khi đã load xong và thực sự không có trips
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, themedStyles.emptyText]}>
{t("diary.noTripsFound")}
</Text>
</View>
);
};
return (
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
<View style={styles.container}>
@@ -189,33 +300,22 @@ export default function diary() {
{t("diary.tripListCount", { count: tripsList?.total || 0 })}
</Text>
{/* Trip List */}
<ScrollView
style={styles.scrollView}
{/* Trip List with FlatList */}
<FlatList
data={allTrips}
renderItem={renderTripItem}
keyExtractor={keyExtractor}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{tripsList?.trips?.map((trip) => (
<TripCard
key={trip.id}
trip={trip}
onPress={() => handleTripPress(trip.id)}
onView={() => handleViewTrip(trip.id)}
onEdit={() => handleEditTrip(trip.id)}
onTeam={() => handleViewTeam(trip.id)}
onSend={() => handleSendTrip(trip.id)}
onDelete={() => handleDeleteTrip(trip.id)}
/>
))}
{(!tripsList || !tripsList.trips || tripsList.trips.length === 0) && (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, themedStyles.emptyText]}>
{t("diary.noTripsFound")}
</Text>
</View>
)}
</ScrollView>
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={10}
initialNumToRender={10}
/>
</View>
{/* Filter Modal */}
@@ -289,9 +389,6 @@ const styles = StyleSheet.create({
default: "System",
}),
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 20,
},
@@ -308,4 +405,19 @@ const styles = StyleSheet.create({
default: "System",
}),
},
loadingFooter: {
paddingVertical: 20,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 10,
},
loadingText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -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>
);

View File

@@ -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,
},
});

View File

@@ -1,76 +0,0 @@
import dayjs from "dayjs";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
type AlarmItem = {
name: string;
t: number;
level: number;
id: string;
};
type AlarmProp = {
alarmsData: AlarmItem[];
onPress?: (alarm: AlarmItem) => void;
};
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
const sortedAlarmsData = [...alarmsData].sort((a, b) => b.level - a.level);
return (
<FlatList
data={sortedAlarmsData}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => onPress?.(item)}
className="flex flex-row gap-5 p-3 justify-start items-baseline w-full"
>
<View
className={`flex-none h-3 w-3 rounded-full ${getBackgroundColorByLevel(
item.level
)}`}
></View>
<View className="flex">
<Text className={`grow text-lg ${getTextColorByLevel(item.level)}`}>
{item.name}
</Text>
<Text className="grow text-md text-gray-400">
{formatTimestamp(item.t)}
</Text>
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => item.id}
/>
);
};
const getBackgroundColorByLevel = (level: number) => {
switch (level) {
case 1:
return "bg-yellow-500";
case 2:
return "bg-orange-500";
case 3:
return "bg-red-500";
default:
return "bg-gray-500";
}
};
const getTextColorByLevel = (level: number) => {
switch (level) {
case 1:
return "text-yellow-600";
case 2:
return "text-orange-600";
case 3:
return "text-red-600";
default:
return "text-gray-600";
}
};
const formatTimestamp = (timestamp: number) => {
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
};
export default AlarmList;

View File

@@ -0,0 +1,197 @@
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
export type AlarmStatus = "confirmed" | "pending";
export interface AlarmListItem {
id: string;
code: string;
title: string;
station: string;
timestamp: number;
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
status: AlarmStatus;
}
type AlarmProp = {
alarmsData: AlarmListItem[];
onPress?: (alarm: AlarmListItem) => void;
};
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
return (
<FlatList
data={alarmsData}
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
ItemSeparatorComponent={() => <View className="h-3" />}
renderItem={({ item }) => (
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
)}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
/>
);
};
type AlarmCardProps = {
alarm: AlarmListItem;
onPress?: () => void;
};
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
alarm.level
);
const statusConfig = getStatusConfig(alarm.status);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
>
<View className="flex-row justify-between items-start">
{/* Left content */}
<View className="flex-row flex-1">
{/* Icon */}
<View
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
>
<Ionicons
name={getIconByLevel(alarm.level)}
size={20}
color={iconColor}
/>
</View>
{/* Info */}
<View className="flex-1">
{/* Code */}
<Text
className={`text-xs font-medium mb-1 ${getCodeTextColor(
alarm.level
)}`}
>
{alarm.code}
</Text>
{/* Title */}
<Text className="text-base font-semibold text-gray-800 mb-2">
{alarm.title}
</Text>
{/* Station and Time */}
<View className="flex-row">
<View className="mr-6">
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
<Text className="text-sm text-gray-600">{alarm.station}</Text>
</View>
<View>
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
<Text className="text-sm text-gray-600">
{formatTimestamp(alarm.timestamp)}
</Text>
</View>
</View>
{/* Status Badge */}
{/* <View className="mt-3">
<View
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
>
<Text
className={`text-xs font-medium ${statusConfig.textColor}`}
>
{statusConfig.label}
</Text>
</View>
</View> */}
</View>
</View>
{/* Checkmark for confirmed */}
{/* {alarm.status === "confirmed" && (
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
<Ionicons name="checkmark" size={16} color="white" />
</View>
)} */}
</View>
</TouchableOpacity>
);
};
const getColorsByLevel = (level: number) => {
switch (level) {
case 3: // Danger - Red
return {
bgColor: "bg-red-50",
borderColor: "border-red-200",
iconColor: "#DC2626",
iconBgColor: "bg-red-100",
};
case 2: // Caution - Yellow/Orange
return {
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
iconColor: "#CA8A04",
iconBgColor: "bg-yellow-100",
};
case 1: // Info - Green
default:
return {
bgColor: "bg-green-50",
borderColor: "border-green-200",
iconColor: "#16A34A",
iconBgColor: "bg-green-100",
};
}
};
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
switch (level) {
case 3:
return "warning";
case 2:
return "alert-circle";
case 1:
default:
return "checkmark-circle";
}
};
const getCodeTextColor = (level: number) => {
switch (level) {
case 3:
return "text-red-600";
case 2:
return "text-yellow-600";
case 1:
default:
return "text-green-600";
}
};
const getStatusConfig = (status: AlarmStatus) => {
switch (status) {
case "confirmed":
return {
label: "Đã xác nhận",
bgColor: "bg-green-100",
textColor: "text-green-700",
};
case "pending":
default:
return {
label: "Chờ xác nhận",
bgColor: "bg-yellow-100",
textColor: "text-yellow-700",
};
}
};
const formatTimestamp = (timestamp: number) => {
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
};
export default AlarmList;

View File

@@ -204,16 +204,16 @@ export default function TripCard({
const styles = StyleSheet.create({
card: {
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderRadius: 8,
padding: 12,
marginBottom: 8,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
height: 1,
},
shadowOpacity: 0.05,
shadowRadius: 8,
shadowRadius: 4,
elevation: 2,
borderWidth: 1,
},
@@ -221,7 +221,7 @@ const styles = StyleSheet.create({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 16,
marginBottom: 8,
},
headerLeft: {
flexDirection: "row",
@@ -229,7 +229,7 @@ const styles = StyleSheet.create({
flex: 1,
},
titleContainer: {
marginLeft: 12,
marginLeft: 8,
flex: 1,
},
title: {
@@ -244,9 +244,9 @@ const styles = StyleSheet.create({
},
badge: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
},
badgeText: {
fontSize: 12,
@@ -258,13 +258,13 @@ const styles = StyleSheet.create({
}),
},
infoGrid: {
gap: 12,
marginBottom: 12,
gap: 6,
marginBottom: 8,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 8,
paddingVertical: 4,
},
label: {
fontSize: 14,
@@ -285,7 +285,7 @@ const styles = StyleSheet.create({
},
divider: {
height: 1,
marginVertical: 12,
marginVertical: 8,
},
actionsContainer: {
flexDirection: "row",
@@ -296,6 +296,7 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingVertical: 4,
},
actionText: {
fontSize: 14,

View File

@@ -0,0 +1,143 @@
import { AlarmData } from "@/app/(tabs)";
import { ThemedText } from "@/components/themed-text";
import { formatTimestamp } from "@/services/time_service";
import { Ionicons } from "@expo/vector-icons";
import { useCallback } from "react";
import { FlatList, TouchableOpacity, View } from "react-native";
// ============ 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",
},
};
// ============ AlarmCard Component ============
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
const config = ALARM_CONFIG[alarm.type];
return (
<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>
{/* 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-xs 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>
);
};
// ============ Main Component ============
interface AlarmListProps {
data: AlarmData[];
onPress?: (alarm: AlarmData) => void;
}
export default function AlarmList({ data, onPress }: AlarmListProps) {
const renderItem = useCallback(
({ item }: { item: AlarmData }) => (
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
),
[onPress]
);
const keyExtractor = useCallback(
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
[]
);
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
ItemSeparatorComponent={ItemSeparator}
contentContainerStyle={{ padding: 16 }}
showsVerticalScrollIndicator={false}
/>
);
}

View File

@@ -0,0 +1,133 @@
import { ANDROID_PLATFORM } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import React, { useRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Circle, MapMarker, Marker } from "react-native-maps";
export interface CircleWithLabelProps {
center: {
latitude: number;
longitude: number;
};
radius: number;
label?: string;
content?: string;
fillColor?: string;
strokeColor?: string;
strokeWidth?: number;
zIndex?: number;
zoomLevel?: number;
}
/**
* Component render Circle kèm Label/Text ở giữa
*/
export const CircleWithLabel: React.FC<CircleWithLabelProps> = ({
center,
radius,
label,
content,
fillColor = "rgba(220, 20, 60, 0.6)",
strokeColor = "rgba(220, 20, 60, 0.8)",
strokeWidth = 2,
zIndex = 50,
zoomLevel = 10,
}) => {
if (!center) {
return null;
}
const platform = usePlatform();
const markerRef = useRef<MapMarker>(null);
// Tính font size dựa trên zoom level
// Zoom càng thấp (xa ra) thì font size càng nhỏ
const calculateFontSize = (baseSize: number) => {
const baseZoom = 10;
// Giảm scale factor để text không quá to khi zoom out
const scaleFactor = Math.pow(2, (zoomLevel - baseZoom) * 0.3);
return Math.max(baseSize * scaleFactor, 5); // Tối thiểu 5px
};
const labelFontSize = calculateFontSize(12);
const contentFontSize = calculateFontSize(10);
const paddingScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.2), 0.5);
const minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
return (
<>
<Circle
center={center}
radius={radius}
fillColor={fillColor}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
zIndex={zIndex}
/>
{label && (
<Marker
ref={markerRef}
coordinate={center}
zIndex={50}
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
anchor={{ x: 0.5, y: 0.5 }}
title={platform === ANDROID_PLATFORM ? label : undefined}
description={platform === ANDROID_PLATFORM ? content : undefined}
>
<View style={styles.markerContainer}>
<View
style={[
{
paddingHorizontal: 5 * paddingScale,
paddingVertical: 5 * paddingScale,
minWidth: 80,
maxWidth: 150 * minWidthScale,
},
]}
>
<Text
style={[styles.labelText, { fontSize: labelFontSize }]}
numberOfLines={2}
>
{label}
</Text>
{content && (
<Text
style={[
styles.contentText,
{ fontSize: contentFontSize, marginTop: 2 * paddingScale },
]}
numberOfLines={2}
>
{content}
</Text>
)}
</View>
</View>
</Marker>
)}
</>
);
};
const styles = StyleSheet.create({
markerContainer: {
alignItems: "center",
justifyContent: "center",
},
labelText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
letterSpacing: 0.3,
textAlign: "center",
},
contentText: {
color: "#fff",
fontSize: 11,
fontWeight: "600",
letterSpacing: 0.2,
textAlign: "center",
opacity: 0.95,
},
});

View File

@@ -0,0 +1,110 @@
import { getShipIcon } from "@/services/map_service";
import React from "react";
import { Animated, Image, StyleSheet, View } from "react-native";
import { Marker } from "react-native-maps";
interface MarkerCustomProps {
id: string;
latitude: number;
longitude: number;
shipName?: string;
description?: string;
stateLevel?: number;
isFishing?: boolean;
heading?: number;
zIndex?: number;
anchor?: { x: number; y: number };
tracksViewChanges?: boolean;
identifier?: string;
animated?: {
scale: Animated.Value;
opacity: Animated.Value;
};
}
export const MarkerCustom: React.FC<MarkerCustomProps> = ({
id,
latitude,
longitude,
shipName,
description,
stateLevel = 0,
isFishing = false,
heading = 0,
zIndex = 50,
anchor = { x: 0.5, y: 0.5 },
tracksViewChanges = false,
identifier,
animated,
}) => {
const uniqueKey =
id || `marker-${latitude.toFixed(6)}-${longitude.toFixed(6)}`;
return (
<Marker
key={uniqueKey}
coordinate={{
latitude,
longitude,
}}
zIndex={zIndex}
anchor={anchor}
title={shipName}
description={description}
tracksViewChanges={tracksViewChanges}
identifier={identifier || uniqueKey}
>
<View className="w-8 h-8 items-center justify-center">
<View style={styles.pingContainer}>
{animated && stateLevel === 3 && (
<Animated.View
style={[
styles.pingCircle,
{
transform: [{ scale: animated.scale }],
opacity: animated.opacity,
},
]}
/>
)}
<Image
source={(() => {
const icon = getShipIcon(stateLevel, isFishing);
return typeof icon === "string" ? { uri: icon } : icon;
})()}
style={{
width: 32,
height: 32,
transform: [
{
rotate: `${
typeof heading === "number" && !isNaN(heading) ? heading : 0
}deg`,
},
],
}}
/>
</View>
</View>
</Marker>
);
};
export default MarkerCustom;
const styles = StyleSheet.create({
pingContainer: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
overflow: "visible",
},
pingCircle: {
position: "absolute",
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ED3F27",
},
});

View File

@@ -0,0 +1,230 @@
import { BanzoneWithAlarm } from "@/app/(tabs)";
import {
convertWKTLineStringToLatLngArray,
convertWKTPointToLatLng,
convertWKTtoLatLngString,
} from "@/utils/geom";
import React, { useEffect, useMemo } from "react";
import { CircleWithLabel } from "./CircleWithLabel";
import { MarkerCustom } from "./MarkerCustom";
import { PolygonWithLabel } from "./PolygonWithLabel";
import { PolylineWithLabel } from "./PolylineWithLabel";
import MapView from "react-native-maps";
interface ZoneInMapProps {
banzones: BanzoneWithAlarm[];
mapRef?: React.RefObject<MapView | null>;
}
// Helper function to parse zone geometry
const parseZoneGeometry = (geometryString: string | undefined) => {
if (!geometryString) {
return null;
}
try {
const geometry: Model.Geom = JSON.parse(geometryString);
return geometry;
} catch (error) {
console.warn("Failed to parse geometry:", error);
return null;
}
};
const ZoneInMap = (data: ZoneInMapProps) => {
const { banzones, mapRef } = data;
// Auto-focus camera to first alarm location when banzones change
useEffect(() => {
if (mapRef?.current && banzones.length > 0) {
const firstAlarm = banzones[0].alarms;
if (
firstAlarm.zone.lat !== undefined &&
firstAlarm.zone.lon !== undefined
) {
setTimeout(() => {
mapRef.current?.animateToRegion(
{
latitude: firstAlarm.zone.lat as number,
longitude: firstAlarm.zone.lon as number,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
},
1000
);
}, 500);
}
}
}, [banzones, mapRef]);
// Parse and render all banzones with their ship markers
const allElements = useMemo(() => {
const elements: React.ReactNode[] = [];
console.log("ZoneInMap - banzones received:", banzones);
banzones.forEach((banzone, banzoneIndex) => {
const { zone, alarms } = banzone;
console.log(`Processing banzone ${banzoneIndex}:`, {
zone: zone,
alarms: alarms,
geometry: zone?.geometry,
});
// Parse geometry with error handling
const geometry = parseZoneGeometry(zone?.geometry);
if (!geometry) {
console.warn(`No geometry for zone ${banzoneIndex}`);
return;
}
const { geom_type, geom_lines, geom_poly, geom_point, geom_radius } =
geometry;
console.log(`Parsed geometry for zone ${banzoneIndex}:`, {
geom_type,
geom_lines: geom_lines?.substring(0, 100) + "...",
geom_poly: geom_poly?.substring(0, 100) + "...",
geom_point,
geom_radius,
});
try {
if (geom_type === 2) {
// LINESTRING - use PolylineWithLabel
// console.log(`Processing LINESTRING for zone ${banzoneIndex}`);
const coordinates = convertWKTLineStringToLatLngArray(
geom_lines || ""
);
// console.log(`Converted coordinates:`, coordinates);
if (coordinates.length > 0) {
elements.push(
<PolylineWithLabel
key={`line-${zone?.id || banzoneIndex}`}
coordinates={coordinates.map((coord) => ({
latitude: coord[0],
longitude: coord[1],
}))}
label={zone?.name || alarms.zone.zone_name || ""}
content={alarms.zone.message || ""}
/>
);
// console.log(`Added PolylineWithLabel for zone ${banzoneIndex}`);
}
} else if (geom_type === 1) {
// MULTIPOLYGON - check both geom_poly and geom_lines
// console.log(`Processing MULTIPOLYGON for zone ${banzoneIndex}`);
``;
// First check if we have actual polygon data
if (geom_poly && geom_poly.trim() !== "") {
const polygons = convertWKTtoLatLngString(geom_poly);
// console.log(`Converted polygons from geom_poly:`, polygons);
polygons.forEach((polygon, polygonIndex) => {
if (polygon.length > 0) {
elements.push(
<PolygonWithLabel
key={`polygon-${zone?.id || banzoneIndex}-${polygonIndex}`}
coordinates={polygon.map((coord) => ({
latitude: coord[0],
longitude: coord[1],
}))}
label={zone?.name || alarms.zone.zone_name || ""}
content={alarms.zone.message || ""}
/>
);
// console.log(
// `Added PolygonWithLabel for zone ${banzoneIndex}-${polygonIndex}`
// );
}
});
} else if (geom_lines && geom_lines.trim() !== "") {
// If no polygon data, treat geom_lines as a line (data inconsistency fix)
// console.log(
// `No polygon data, processing as LINESTRING from geom_lines`
// );
const coordinates = convertWKTLineStringToLatLngArray(geom_lines);
console.log(`Converted coordinates from geom_lines:`, coordinates);
if (coordinates.length > 0) {
elements.push(
<PolylineWithLabel
key={`line-${zone?.id || banzoneIndex}`}
coordinates={coordinates.map((coord) => ({
latitude: coord[0],
longitude: coord[1],
}))}
label={zone?.name || alarms.zone.zone_name || ""}
content={alarms.zone.message || ""}
/>
);
// console.log(
// `Added PolylineWithLabel for zone ${banzoneIndex} (from geom_lines)`
// );
}
} else {
// console.warn(`No valid geometry data for zone ${banzoneIndex}`);
}
} else if (geom_type === 3) {
// POINT/CIRCLE - use Circle
// console.log(`Processing POINT/CIRCLE for zone ${banzoneIndex}`);
const point = convertWKTPointToLatLng(geom_point || "");
// console.log(`Converted point:`, point, `radius:`, geom_radius);
if (point && geom_radius) {
elements.push(
<CircleWithLabel
key={`circle-${zone?.id || banzoneIndex}`}
center={{
latitude: point[1], // Note: convertWKTPointToLatLng returns [lng, lat]
longitude: point[0],
}}
radius={geom_radius}
label={zone?.name || alarms.zone.zone_name || ""}
content={alarms.zone.message || ""}
/>
);
// console.log(`Added Circle for zone ${banzoneIndex}`);
}
} else {
console.warn(
`Unknown geom_type ${geom_type} for zone ${banzoneIndex}`
);
}
} catch (error) {
console.warn(
"Error processing zone geometry for zone",
zone?.id,
":",
error
);
}
// Ship marker for the alarm location
if (alarms.zone.lat && alarms.zone.lon) {
elements.push(
<MarkerCustom
key={`ship-${alarms.thing_id || banzoneIndex}`}
id={`ship-${alarms.thing_id || banzoneIndex}`}
latitude={alarms.zone.lat}
longitude={alarms.zone.lon}
shipName={alarms.ship_name || "Tàu không xác định"}
description={
alarms.zone.gps_time
? new Date(alarms.zone.gps_time * 1000).toLocaleString()
: ""
}
heading={alarms.zone.h}
zIndex={100}
/>
);
}
});
// console.log(`Total elements rendered: ${elements.length}`);
return elements;
}, [banzones]);
return <>{allElements}</>;
};
export default ZoneInMap;

View File

@@ -4,3 +4,7 @@ import { API_GET_ALL_BANZONES } from "@/constants";
export async function queryBanzones() {
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
}
export async function queryBanzoneById(zoneId: string) {
return api.get<Model.Zone>(`${API_GET_ALL_BANZONES}/${zoneId}`);
}

View File

@@ -60,7 +60,9 @@ declare namespace Model {
conditions?: Condition[];
enabled?: boolean;
updated_at?: Date;
geom?: Geom;
geometry?: string;
description?: string;
province_code?: string;
}
interface Condition {

View File

@@ -67,6 +67,7 @@
"tripList": "Trip List",
"tripListCount": "Trip List ({{count}})",
"noTripsFound": "No matching trips found",
"loadingMore": "Loading more...",
"reset": "Reset",
"apply": "Apply",
"selectedFilters": "Selected filters:",

View File

@@ -67,6 +67,7 @@
"tripList": "Danh sách chuyến đi",
"tripListCount": "Danh sách chuyến đi ({{count}})",
"noTripsFound": "Không tìm thấy chuyến đi phù hợp",
"loadingMore": "Đang tải thêm...",
"reset": "Đặt lại",
"apply": "Áp dụng",
"selectedFilters": "Bộ lọc đã chọn:",

View File

@@ -41,3 +41,8 @@ export function formatRelativeTime(unixTime: number): string {
if (diffMonths < 12) return `${diffMonths} tháng trước`;
return `${diffYears} năm trước`;
}
export const formatTimestamp = (timestamp?: number): string => {
if (!timestamp) return "N/A";
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
};