Compare commits

..

6 Commits

Author SHA1 Message Date
Tran Anh Tuan
efe9749a8e sửa lỗi polygon không bị xóa ở map 2025-11-03 10:33:30 +07:00
67a80c1498 update netDetailModal 2025-11-02 23:39:17 +07:00
5cc760f818 update crewDetailModal 2025-11-02 13:42:50 +07:00
52d2f0f78b update login, modal detail in tripInfo 2025-11-01 19:47:45 +07:00
eea1482a88 Update from MinhNN 2025-11-01 00:59:04 +07:00
44fc6848a8 update login, detail table in tripInfo 2025-10-31 23:55:39 +07:00
27 changed files with 2189 additions and 450 deletions

View File

@@ -1,4 +1,12 @@
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import { Link } from "expo-router";
import {
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
export default function Warning() { export default function Warning() {
@@ -7,6 +15,12 @@ export default function Warning() {
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.titleText}>Nhật Chuyến Đi</Text> <Text style={styles.titleText}>Nhật Chuyến Đi</Text>
<Link href="/modal" asChild>
<TouchableOpacity style={styles.button}>
<Text style={styles.buttonText}>Mở Modal</Text>
</TouchableOpacity>
</Link>
</View> </View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
@@ -32,4 +46,16 @@ const styles = StyleSheet.create({
default: "System", default: "System",
}), }),
}, },
button: {
backgroundColor: "#007AFF",
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 8,
marginTop: 20,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
}); });

View File

@@ -1,369 +1,188 @@
import { PolygonWithLabel } from "@/components/map/PolygonWithLabel"; import { PolygonWithLabel } from "@/components/map/PolygonWithLabel";
import { PolylineWithLabel } from "@/components/map/PolylineWithLabel"; import { PolylineWithLabel } from "@/components/map/PolylineWithLabel";
import { showToastError } from "@/config";
import { import {
AUTO_REFRESH_INTERVAL,
ENTITY, ENTITY,
EVENT_ALARM_DATA,
EVENT_BANZONE_DATA,
EVENT_ENTITY_DATA,
EVENT_GPS_DATA,
EVENT_TRACK_POINTS_DATA,
IOS_PLATFORM, IOS_PLATFORM,
LIGHT_THEME, LIGHT_THEME,
} from "@/constants"; } from "@/constants";
import {
queryAlarm,
queryEntities,
queryGpsData,
queryTrackPoints,
} from "@/controller/DeviceController";
import { useColorScheme } from "@/hooks/use-color-scheme.web"; import { useColorScheme } from "@/hooks/use-color-scheme.web";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
import {
getAlarmEventBus,
getBanzonesEventBus,
getEntitiesEventBus,
getGpsEventBus,
getTrackPointsEventBus,
} from "@/services/device_events";
import { getShipIcon } from "@/services/map_service"; import { getShipIcon } from "@/services/map_service";
import { useBanzones } from "@/state/use-banzones"; import eventBus from "@/utils/eventBus";
import { import {
convertWKTLineStringToLatLngArray, convertWKTLineStringToLatLngArray,
convertWKTtoLatLngString, convertWKTtoLatLngString,
} from "@/utils/geom"; } from "@/utils/geom";
import { Image as ExpoImage } from "expo-image";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import {
Animated,
Image as RNImage,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import MapView, { Circle, Marker } from "react-native-maps"; import MapView, { Circle, Marker } from "react-native-maps";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
const testPolyline =
"MULTIPOLYGON(((108.7976074 17.5392966,110.390625 14.2217886,109.4677734 10.8548863,112.9227161 10.6933337,116.4383411 12.565622,116.8997669 17.0466095,109.8685169 17.8013229,108.7973446 17.5393669,108.7976074 17.5392966)))";
export default function HomeScreen() { export default function HomeScreen() {
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null); const [gpsData, setGpsData] = useState<Model.GPSResonse | undefined>(
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null); undefined
const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>(
null
); );
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
const [entityData, setEntityData] = useState<
Model.TransformedEntity[] | null
>(null);
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
const [trackPointsData, setTrackPointsData] = useState<
Model.ShipTrackPoint[] | null
>(null);
const [circleRadius, setCircleRadius] = useState(100); const [circleRadius, setCircleRadius] = useState(100);
const [zoomLevel, setZoomLevel] = useState(10); const [zoomLevel, setZoomLevel] = useState(10);
const [isFirstLoad, setIsFirstLoad] = useState(true); const [isFirstLoad, setIsFirstLoad] = useState(true);
const [polylineCoordinates, setPolylineCoordinates] = useState< const [polylineCoordinates, setPolylineCoordinates] = useState<
number[][] | null number[][] | undefined
>(null); >(undefined);
const [polygonCoordinates, setPolygonCoordinates] = useState< const [polygonCoordinates, setPolygonCoordinates] = useState<
number[][][] | null number[][][] | undefined
>(null); >(undefined);
const [, setZoneGeometries] = useState<Map<string, any>>(new Map());
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const platform = usePlatform(); const platform = usePlatform();
const theme = useColorScheme(); const theme = useColorScheme();
const { banzones, getBanzone } = useBanzones(); const scale = useRef(new Animated.Value(0)).current;
const banzonesRef = useRef(banzones); const opacity = useRef(new Animated.Value(1)).current;
// console.log("Platform: ", platform); // console.log("Platform: ", platform);
// console.log("Theme: ", theme); // console.log("Theme: ", theme);
const getGpsData = async () => { // const [number, setNumber] = useState(0);
try {
const response = await queryGpsData();
// console.log("GpsData: ", response.data);
// console.log(
// "Heading value:",
// response.data?.h,
// "Type:",
// typeof response.data?.h
// );
setGpsData(response.data);
} catch (error) {
console.error("Error fetching GPS data:", error);
showToastError("Lỗi", "Không thể lấy dữ liệu GPS");
}
};
const drawPolyline = () => {
const data = convertWKTtoLatLngString(testPolyline);
console.log("Data: ", data);
// setPolygonCoordinates(data[0]);
// ;
// console.log("Banzones: ", banzones.length);
};
useEffect(() => { useEffect(() => {
banzonesRef.current = banzones; getGpsEventBus();
}, [banzones]); getAlarmEventBus();
getEntitiesEventBus();
getBanzonesEventBus();
getTrackPointsEventBus();
const queryGpsData = (gpsData: Model.GPSResonse) => {
setGpsData(gpsData);
};
const queryAlarmData = (alarmData: Model.AlarmResponse) => {
// console.log("Alarm Data: ", alarmData.alarms.length);
setAlarmData(alarmData);
};
const queryEntityData = (entityData: Model.TransformedEntity[]) => {
// console.log("Entities Length Data: ", entityData.length);
const areGeometriesEqual = ( setEntityData(entityData);
left?: { };
geom_type: number; const queryBanzonesData = (banzoneData: Model.Zone[]) => {
geom_lines?: string | null; // console.log("Banzone Data: ", banzoneData.length);
geom_poly?: string | null;
},
right?: {
geom_type: number;
geom_lines?: string | null;
geom_poly?: string | null;
}
) => {
if (!left && !right) {
return true;
}
if (!left || !right) { setBanzoneData(banzoneData);
return false; };
} const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
// console.log("TrackPoints Data: ", TrackPointsData.length);
setTrackPointsData(TrackPointsData);
};
return ( eventBus.on(EVENT_GPS_DATA, queryGpsData);
left.geom_type === right.geom_type && console.log("Registering event handlers in HomeScreen");
(left.geom_lines || "") === (right.geom_lines || "") && eventBus.on(EVENT_GPS_DATA, queryGpsData);
(left.geom_poly || "") === (right.geom_poly || "") console.log("Subscribed to EVENT_GPS_DATA");
); eventBus.on(EVENT_ALARM_DATA, queryAlarmData);
}; console.log("Subscribed to EVENT_ALARM_DATA");
eventBus.on(EVENT_ENTITY_DATA, queryEntityData);
console.log("Subscribed to EVENT_ENTITY_DATA");
eventBus.on(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
console.log("Subscribed to EVENT_TRACK_POINTS_DATA");
eventBus.once(EVENT_BANZONE_DATA, queryBanzonesData);
console.log("Subscribed once to EVENT_BANZONE_DATA");
const areCoordinatesEqual = (
current: number[][] | null,
next: number[][] | null
) => {
if (!current || !next || current.length !== next.length) {
return false;
}
return current.every(
(coord, index) =>
coord[0] === next[index][0] && coord[1] === next[index][1]
);
};
const areMultiPolygonCoordinatesEqual = (
current: number[][][] | null,
next: number[][][] | null
) => {
if (!current || !next || current.length !== next.length) {
return false;
}
return current.every((polygon, polyIndex) => {
const nextPolygon = next[polyIndex];
if (!nextPolygon || polygon.length !== nextPolygon.length) {
return false;
}
return polygon.every(
(coord, coordIndex) =>
coord[0] === nextPolygon[coordIndex][0] &&
coord[1] === nextPolygon[coordIndex][1]
);
});
};
const getAlarmData = async () => {
try {
const response = await queryAlarm();
// console.log("AlarmData: ", response.data);
setAlarmData(response.data);
} catch (error) {
console.error("Error fetching Alarm Data: ", error);
showToastError("Lỗi", "Không thể lấy dữ liệu báo động");
}
};
const getEntities = async () => {
try {
const entities = await queryEntities();
if (!entities) {
// Clear tất cả khu vực khi không có dữ liệu
setPolylineCoordinates(null);
setPolygonCoordinates(null);
setZoneGeometries(new Map());
return;
}
const currentBanzones = banzonesRef.current || [];
let nextPolyline: number[][] | null = null;
let nextMultiPolygon: number[][][] | null = null;
let foundPolyline = false;
let foundPolygon = false;
// Process zones để tìm geometries
for (const entity of entities) {
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
continue;
}
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(null);
setPolygonCoordinates(null);
setZoneGeometries(new Map());
return;
}
for (const zone of zones) {
const geom = currentBanzones.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) {
foundPolyline = true;
const coordinates = convertWKTLineStringToLatLngArray(
geom_lines || ""
);
if (coordinates.length > 0) {
nextPolyline = coordinates;
}
} else if (geom_type === 1) {
foundPolygon = true;
const coordinates = convertWKTtoLatLngString(geom_poly || "");
if (coordinates.length > 0) {
console.log("Polygon Coordinate: ", coordinates);
nextMultiPolygon = coordinates;
}
}
}
}
// Update state sau khi đã process xong
setZoneGeometries((prevGeometries) => {
const updated = new Map(prevGeometries);
let hasChanges = false;
for (const entity of entities) {
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
continue;
}
let zones: any[] = [];
try {
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
} catch {
continue;
}
if (zones.length === 0) {
if (updated.size > 0) {
hasChanges = true;
updated.clear();
}
break;
}
for (const zone of zones) {
const geom = currentBanzones.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;
}
const key = `${zone.zone_id}_${geom_type}`;
const newGeomData = { geom_type, geom_lines, geom_poly };
const oldGeom = updated.get(key);
if (!areGeometriesEqual(oldGeom, newGeomData)) {
hasChanges = true;
updated.set(key, newGeomData);
console.log("Geometry changed", { key, oldGeom, newGeomData });
}
}
}
return hasChanges ? updated : prevGeometries;
});
// Cập nhật hoặc clear polyline
if (foundPolyline && nextPolyline) {
setPolylineCoordinates((prev) =>
areCoordinatesEqual(prev, nextPolyline) ? prev : nextPolyline
);
} else if (!foundPolyline) {
console.log("Hết cảnh báo qua polyline");
setPolylineCoordinates(null);
}
// Cập nhật hoặc clear polygon
if (foundPolygon && nextMultiPolygon) {
setPolygonCoordinates((prev) =>
areMultiPolygonCoordinatesEqual(prev, nextMultiPolygon)
? prev
: nextMultiPolygon
);
} else if (!foundPolygon) {
console.log("Hết cảnh báo qua polygon");
setPolygonCoordinates(null);
}
} catch (error) {
console.error("Error fetching Entities: ", error);
// Clear tất cả khi có lỗi
setPolylineCoordinates(null);
setPolygonCoordinates(null);
setZoneGeometries(new Map());
}
};
const getShipTrackPoints = async () => {
try {
const response = await queryTrackPoints();
// console.log("TrackPoints Data Length: ", response.data.length);
setTrackPoints(response.data);
} catch (error) {
console.error("Error fetching TrackPoints Data: ", error);
showToastError("Lỗi", "Không thể lấy lịch sử di chuyển");
}
};
const fetchAllData = async () => {
await Promise.all([
getGpsData(),
getAlarmData(),
getShipTrackPoints(),
getEntities(),
]);
};
const setupAutoRefresh = () => {
// Clear existing interval if any
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Set new interval to refresh data every 5 seconds
// Không fetch banzones vì dữ liệu không thay đổi
intervalRef.current = setInterval(async () => {
// console.log("Auto-refreshing data...");
await fetchAllData();
}, AUTO_REFRESH_INTERVAL);
};
const handleMapReady = () => {
// console.log("Map loaded successfully!");
// Gọi fetchAllData ngay lập tức (không cần đợi banzones)
fetchAllData();
setupAutoRefresh();
// Set isFirstLoad to false sau khi map ready để chỉ zoom lần đầu tiên
setTimeout(() => {
setIsFirstLoad(false);
}, 2000);
};
// Cleanup interval on component unmount
useEffect(() => {
return () => { return () => {
if (intervalRef.current) { console.log("Unregistering event handlers in HomeScreen");
clearInterval(intervalRef.current); eventBus.off(EVENT_GPS_DATA, queryGpsData);
} console.log("Unsubscribed EVENT_GPS_DATA");
eventBus.off(EVENT_ALARM_DATA, queryAlarmData);
console.log("Unsubscribed EVENT_ALARM_DATA");
eventBus.off(EVENT_ENTITY_DATA, queryEntityData);
console.log("Unsubscribed EVENT_ENTITY_DATA");
eventBus.off(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
console.log("Unsubscribed EVENT_TRACK_POINTS_DATA");
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
getBanzone(); if (polylineCoordinates !== undefined) {
}, [getBanzone]); console.log("Polyline Khac null");
} else {
console.log("Polyline null");
}
}, [polylineCoordinates]);
useEffect(() => {
setPolylineCoordinates(undefined);
setPolygonCoordinates(undefined);
if (!entityData) return;
if (!banzoneData) return;
for (const entity of entityData) {
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
continue;
}
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(undefined);
setPolygonCoordinates(undefined);
return;
}
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) {
setPolylineCoordinates(coordinates);
}
} else if (geom_type === 1) {
// foundPolygon = true;
const coordinates = convertWKTtoLatLngString(geom_poly || "");
if (coordinates.length > 0) {
console.log("Polygon Coordinate: ", coordinates);
setPolygonCoordinates(coordinates);
}
}
}
}
}, [banzoneData, entityData]);
// Hàm tính radius cố định khi zoom change // Hàm tính radius cố định khi zoom change
const calculateRadiusFromZoom = (zoom: number) => { const calculateRadiusFromZoom = (zoom: number) => {
@@ -410,16 +229,51 @@ export default function HomeScreen() {
}; };
}; };
const handleMapReady = () => {
setTimeout(() => {
setIsFirstLoad(false);
}, 2000);
};
useEffect(() => {
if (alarmData?.level === 3) {
const loop = Animated.loop(
Animated.sequence([
Animated.parallel([
Animated.timing(scale, {
toValue: 3, // nở to 3 lần
duration: 1500,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0, // mờ dần
duration: 1500,
useNativeDriver: true,
}),
]),
Animated.parallel([
Animated.timing(scale, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 0,
useNativeDriver: true,
}),
]),
])
);
loop.start();
return () => loop.stop();
}
}, [alarmData?.level, scale, opacity]);
return ( return (
<SafeAreaProvider style={styles.container}> <SafeAreaView edges={["top"]} style={styles.container}>
{banzones.length > 0 && (
<Text className="hidden">Banzones loaded: {banzones.length}</Text>
)}
<MapView <MapView
onMapReady={handleMapReady} onMapReady={handleMapReady}
onPoiClick={(point) => {
console.log("Poi clicked: ", point.nativeEvent);
}}
onRegionChangeComplete={handleRegionChangeComplete} onRegionChangeComplete={handleRegionChangeComplete}
style={styles.map} style={styles.map}
// initialRegion={getMapRegion()} // initialRegion={getMapRegion()}
@@ -428,11 +282,12 @@ export default function HomeScreen() {
showsBuildings={false} showsBuildings={false}
showsIndoors={false} showsIndoors={false}
loadingEnabled={true} loadingEnabled={true}
mapType="standard" mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
rotateEnabled={false}
> >
{trackPoints && {trackPointsData &&
trackPoints.length > 0 && trackPointsData.length > 0 &&
trackPoints.map((point, index) => { trackPointsData.map((point, index) => {
// console.log(`Rendering circle ${index}:`, point); // console.log(`Rendering circle ${index}:`, point);
return ( return (
<Circle <Circle
@@ -442,14 +297,14 @@ export default function HomeScreen() {
longitude: point.lon, longitude: point.lon,
}} }}
zIndex={50} zIndex={50}
radius={circleRadius} radius={platform === IOS_PLATFORM ? 200 : 50}
fillColor="rgba(16, 85, 201, 0.6)" strokeColor="rgba(16, 85, 201, 0.7)"
strokeColor="rgba(16, 85, 201, 0.8)" fillColor="rgba(16, 85, 201, 0.7)"
strokeWidth={2} strokeWidth={2}
/> />
); );
})} })}
{polylineCoordinates && ( {polylineCoordinates !== undefined && (
<PolylineWithLabel <PolylineWithLabel
coordinates={polylineCoordinates.map((coord) => ({ coordinates={polylineCoordinates.map((coord) => ({
latitude: coord[0], latitude: coord[0],
@@ -462,7 +317,7 @@ export default function HomeScreen() {
zIndex={50} zIndex={50}
/> />
)} )}
{polygonCoordinates && polygonCoordinates.length > 0 && ( {polygonCoordinates !== undefined && (
<> <>
{polygonCoordinates.map((polygon, index) => { {polygonCoordinates.map((polygon, index) => {
// Tạo key ổn định từ tọa độ đầu tiên của polygon // Tạo key ổn định từ tọa độ đầu tiên của polygon
@@ -472,6 +327,17 @@ export default function HomeScreen() {
: `polygon-${index}`; : `polygon-${index}`;
return ( return (
// <Polygon
// key={polygonKey}
// coordinates={polygon.map((coords) => ({
// latitude: coords[0],
// longitude: coords[1],
// }))}
// fillColor="rgba(16, 85, 201, 0.6)"
// strokeColor="rgba(16, 85, 201, 0.8)"
// strokeWidth={2}
// zIndex={50}
// />
<PolygonWithLabel <PolygonWithLabel
key={polygonKey} key={polygonKey}
coordinates={polygon.map((coords) => ({ coordinates={polygon.map((coords) => ({
@@ -490,7 +356,7 @@ export default function HomeScreen() {
})} })}
</> </>
)} )}
{gpsData && ( {gpsData !== undefined && (
<Marker <Marker
coordinate={{ coordinate={{
latitude: gpsData.lat, latitude: gpsData.lat,
@@ -501,34 +367,63 @@ export default function HomeScreen() {
? "Tàu của mình - iOS" ? "Tàu của mình - iOS"
: "Tàu của mình - Android" : "Tàu của mình - Android"
} }
zIndex={100} zIndex={200}
anchor={{ x: 0.5, y: 0.5 }} anchor={
platform === IOS_PLATFORM
? { x: 0.5, y: 0.5 }
: { x: 0.5, y: 0.4 }
}
> >
<View className="w-8 h-8 items-center justify-center"> <View className="w-8 h-8 items-center justify-center">
<ExpoImage <View style={styles.pingContainer}>
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)} {alarmData?.level === 3 && (
style={{ <Animated.View
width: 32, style={[
height: 32, styles.pingCircle,
transform: [ {
{ transform: [{ scale }],
rotate: `${ opacity,
typeof gpsData.h === "number" && !isNaN(gpsData.h) },
? gpsData.h ]}
: 0 />
}deg`, )}
}, <RNImage
], source={(() => {
}} const icon = getShipIcon(
/> alarmData?.level || 0,
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> </View>
</Marker> </Marker>
)} )}
</MapView> </MapView>
<TouchableOpacity style={styles.button} onPress={drawPolyline}> <TouchableOpacity
style={styles.button}
onPress={() => {
setPolygonCoordinates(undefined);
setPolylineCoordinates(undefined);
}}
>
<Text style={styles.buttonText}>Get GPS Data</Text> <Text style={styles.buttonText}>Get GPS Data</Text>
</TouchableOpacity> </TouchableOpacity>
</SafeAreaProvider> </SafeAreaView>
); );
} }
@@ -540,7 +435,7 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
button: { button: {
display: "none", // display: "none",
position: "absolute", position: "absolute",
top: 50, top: 50,
right: 20, right: 20,
@@ -562,20 +457,25 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
}, },
titleContainer: {
flexDirection: "row", pingContainer: {
width: 32,
height: 32,
alignItems: "center", alignItems: "center",
gap: 8, justifyContent: "center",
overflow: "visible",
}, },
stepContainer: { pingCircle: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: "absolute", position: "absolute",
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ED3F27",
},
centerDot: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "#0096FF",
}, },
}); });

View File

@@ -10,7 +10,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
export default function TripInfoScreen() { export default function TripInfoScreen() {
return ( return (
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.titleText}>Thông Tin Chuyến Đi</Text> <Text style={styles.titleText}>Thông Tin Chuyến Đi</Text>
<View style={styles.buttonWrapper}> <View style={styles.buttonWrapper}>
@@ -34,6 +34,10 @@ export default function TripInfoScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safeArea: {
flex: 1,
paddingBottom: 5,
},
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
}, },
@@ -57,6 +61,7 @@ const styles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
gap: 10, gap: 10,
marginTop: 15, marginTop: 15,
marginBottom: 15,
}, },
titleText: { titleText: {
fontSize: 32, fontSize: 32,

View File

@@ -10,9 +10,10 @@ import {
} from "@/utils/storage"; } from "@/utils/storage";
import { parseJwtToken } from "@/utils/token"; import { parseJwtToken } from "@/utils/token";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Image,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
ScrollView, ScrollView,
@@ -98,8 +99,14 @@ export default function LoginScreen() {
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
{/* Header */} {/* Header */}
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
{/* Logo */}
<Image
source={require("@/assets/images/logo.png")}
style={styles.logo}
resizeMode="contain"
/>
<ThemedText type="title" style={styles.title}> <ThemedText type="title" style={styles.title}>
SGW App Hệ thống giám sát tàu
</ThemedText> </ThemedText>
<ThemedText style={styles.subtitle}> <ThemedText style={styles.subtitle}>
Đăng nhập đ tiếp tục Đăng nhập đ tiếp tục
@@ -160,6 +167,13 @@ export default function LoginScreen() {
<Text style={styles.linkText}>Đăng ngay</Text> <Text style={styles.linkText}>Đăng ngay</Text>
</ThemedText> </ThemedText>
</View> </View>
{/* Copyright */}
<View style={styles.copyrightContainer}>
<ThemedText style={styles.copyrightText}>
© {new Date().getFullYear()} - Sản phẩm của Mobifone
</ThemedText>
</View>
</View> </View>
</ThemedView> </ThemedView>
</ScrollView> </ScrollView>
@@ -181,8 +195,13 @@ const styles = StyleSheet.create({
marginBottom: 40, marginBottom: 40,
alignItems: "center", alignItems: "center",
}, },
logo: {
width: 120,
height: 120,
marginBottom: 20,
},
title: { title: {
fontSize: 32, fontSize: 28,
fontWeight: "bold", fontWeight: "bold",
marginBottom: 8, marginBottom: 8,
}, },
@@ -236,4 +255,13 @@ const styles = StyleSheet.create({
color: "#007AFF", color: "#007AFF",
fontWeight: "600", fontWeight: "600",
}, },
copyrightContainer: {
marginTop: 20,
alignItems: "center",
},
copyrightText: {
fontSize: 12,
opacity: 0.6,
textAlign: "center",
},
}); });

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/images/owner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,5 +1,5 @@
import { getPolygonCenter } from "@/utils/polyline"; import { getPolygonCenter } from "@/utils/polyline";
import React, { memo } from "react"; import React from "react";
import { StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { Marker, Polygon } from "react-native-maps"; import { Marker, Polygon } from "react-native-maps";
@@ -20,7 +20,7 @@ export interface PolygonWithLabelProps {
/** /**
* Component render Polygon kèm Label/Text ở giữa * Component render Polygon kèm Label/Text ở giữa
*/ */
const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({ export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
coordinates, coordinates,
label, label,
content, content,
@@ -65,7 +65,7 @@ const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
{label && ( {label && (
<Marker <Marker
coordinate={centerPoint} coordinate={centerPoint}
zIndex={200} zIndex={50}
tracksViewChanges={false} tracksViewChanges={false}
anchor={{ x: 0.5, y: 0.5 }} anchor={{ x: 0.5, y: 0.5 }}
> >
@@ -142,24 +142,3 @@ const styles = StyleSheet.create({
opacity: 0.95, opacity: 0.95,
}, },
}); });
// Export memoized component để tránh re-render không cần thiết
export const PolygonWithLabel = memo(
PolygonWithLabelComponent,
(prev, next) => {
// Custom comparison: chỉ re-render khi coordinates, label, content hoặc zoomLevel thay đổi
return (
prev.coordinates.length === next.coordinates.length &&
prev.coordinates.every(
(coord, index) =>
coord.latitude === next.coordinates[index]?.latitude &&
coord.longitude === next.coordinates[index]?.longitude
) &&
prev.label === next.label &&
prev.content === next.content &&
prev.zoomLevel === next.zoomLevel &&
prev.fillColor === next.fillColor &&
prev.strokeColor === next.strokeColor
);
}
);

View File

@@ -2,7 +2,7 @@ import {
calculateTotalDistance, calculateTotalDistance,
getMiddlePointOfPolyline, getMiddlePointOfPolyline,
} from "@/utils/polyline"; } from "@/utils/polyline";
import React, { memo } from "react"; import React from "react";
import { StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { Marker, Polyline } from "react-native-maps"; import { Marker, Polyline } from "react-native-maps";
@@ -21,7 +21,7 @@ export interface PolylineWithLabelProps {
/** /**
* Component render Polyline kèm Label/Text ở giữa * Component render Polyline kèm Label/Text ở giữa
*/ */
const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({ export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
coordinates, coordinates,
label, label,
strokeColor = "#FF5733", strokeColor = "#FF5733",
@@ -103,22 +103,3 @@ const styles = StyleSheet.create({
textAlign: "center", textAlign: "center",
}, },
}); });
// Export memoized component để tránh re-render không cần thiết
export const PolylineWithLabel = memo(
PolylineWithLabelComponent,
(prev, next) => {
// Custom comparison: chỉ re-render khi coordinates, label hoặc showDistance thay đổi
return (
prev.coordinates.length === next.coordinates.length &&
prev.coordinates.every(
(coord, index) =>
coord.latitude === next.coordinates[index]?.latitude &&
coord.longitude === next.coordinates[index]?.longitude
) &&
prev.label === next.label &&
prev.showDistance === next.showDistance &&
prev.strokeColor === next.strokeColor
);
}
);

View File

@@ -1,6 +1,7 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native"; import { Animated, Text, TouchableOpacity, View } from "react-native";
import CrewDetailModal from "./modal/CrewDetailModal";
import styles from "./style/CrewListTable.styles"; import styles from "./style/CrewListTable.styles";
// --------------------------- // ---------------------------
@@ -11,26 +12,84 @@ interface CrewMember {
maDinhDanh: string; maDinhDanh: string;
ten: string; ten: string;
chucVu: string; chucVu: string;
ngaySinh?: string;
cccd?: string;
soDienThoai?: string;
diaChi?: string;
ngayVaoLam?: string;
trinhDoChuyenMon?: string;
bangCap?: string;
tinhTrang?: string;
} }
// --------------------------- // ---------------------------
// ⚓ Dữ liệu mẫu // ⚓ Dữ liệu mẫu
// --------------------------- // ---------------------------
const data: CrewMember[] = [ const data: CrewMember[] = [
{
id: "10",
maDinhDanh: "ChuTau",
ten: "Nguyễn Nhật Minh",
chucVu: "Chủ tàu",
ngaySinh: "08/06/2006",
cccd: "079085012345",
soDienThoai: "0912345678",
diaChi: "Hà Nội",
ngayVaoLam: "",
trinhDoChuyenMon: "Thuyền trưởng hạng I",
bangCap: "Bằng thuyền trưởng xa bờ",
tinhTrang: "Đang làm việc",
},
{ {
id: "1", id: "1",
maDinhDanh: "TV001", maDinhDanh: "TV001",
ten: "Nguyễn Văn A", ten: "Nguyễn Văn A",
chucVu: "Thuyền trưởng", chucVu: "Thuyền trưởng",
ngaySinh: "20/05/1988",
cccd: "079088011111",
soDienThoai: "0901234567",
diaChi: "456 Đường Cảng, Phường Thanh Khê, Đà Nẵng",
ngayVaoLam: "15/06/2015",
trinhDoChuyenMon: "Thuyền trưởng hạng II",
bangCap: "Bằng thuyền trưởng ven bờ",
tinhTrang: "Đang làm việc",
},
{
id: "2",
maDinhDanh: "TV002",
ten: "Trần Văn B",
chucVu: "Máy trưởng",
ngaySinh: "10/08/1990",
cccd: "079090022222",
soDienThoai: "0987654321",
diaChi: "789 Đường Nguyễn Văn Linh, Quận Sơn Trà, Đà Nẵng",
ngayVaoLam: "20/03/2016",
trinhDoChuyenMon: "Máy trưởng hạng III",
bangCap: "Bằng máy trưởng ven bờ",
tinhTrang: "Đang làm việc",
},
{
id: "3",
maDinhDanh: "TV003",
ten: "Lê Văn C",
chucVu: "Thủy thủ",
ngaySinh: "25/12/1995",
cccd: "079095033333",
soDienThoai: "0976543210",
diaChi: "321 Đường Hoàng Sa, Quận Ngũ Hành Sơn, Đà Nẵng",
ngayVaoLam: "10/07/2018",
trinhDoChuyenMon: "Thủy thủ hạng I",
bangCap: "Chứng chỉ thủy thủ",
tinhTrang: "Đang làm việc",
}, },
{ id: "2", maDinhDanh: "TV002", ten: "Trần Văn B", chucVu: "Máy trưởng" },
{ id: "3", maDinhDanh: "TV003", ten: "Lê Văn C", chucVu: "Thủy thủ" },
]; ];
const CrewListTable: React.FC = () => { const CrewListTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0); const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current; const animatedHeight = useRef(new Animated.Value(0)).current;
const [modalVisible, setModalVisible] = useState(false);
const [selectedCrew, setSelectedCrew] = useState<CrewMember | null>(null);
const tongThanhVien = data.length; const tongThanhVien = data.length;
const handleToggle = () => { const handleToggle = () => {
@@ -43,6 +102,19 @@ const CrewListTable: React.FC = () => {
setCollapsed((prev) => !prev); setCollapsed((prev) => !prev);
}; };
const handleCrewPress = (crewId: string) => {
const crew = data.find((item) => item.id === crewId);
if (crew) {
setSelectedCrew(crew);
setModalVisible(true);
}
};
const handleCloseModal = () => {
setModalVisible(false);
setSelectedCrew(null);
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Header toggle */} {/* Header toggle */}
@@ -77,7 +149,9 @@ const CrewListTable: React.FC = () => {
<Text style={[styles.cell, styles.left, styles.headerText]}> <Text style={[styles.cell, styles.left, styles.headerText]}>
đnh danh đnh danh
</Text> </Text>
<Text style={[styles.cell, styles.headerText]}>Tên</Text> <View style={styles.cellWrapper}>
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
</View>
<Text style={[styles.cell, styles.right, styles.headerText]}> <Text style={[styles.cell, styles.right, styles.headerText]}>
Chức vụ Chức vụ
</Text> </Text>
@@ -87,7 +161,12 @@ const CrewListTable: React.FC = () => {
{data.map((item) => ( {data.map((item) => (
<View key={item.id} style={styles.row}> <View key={item.id} style={styles.row}>
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text> <Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
<Text style={[styles.cell]}>{item.ten}</Text> <TouchableOpacity
style={styles.cellWrapper}
onPress={() => handleCrewPress(item.id)}
>
<Text style={[styles.cell, styles.linkText]}>{item.ten}</Text>
</TouchableOpacity>
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text> <Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
</View> </View>
))} ))}
@@ -109,7 +188,9 @@ const CrewListTable: React.FC = () => {
<Text style={[styles.cell, styles.left, styles.headerText]}> <Text style={[styles.cell, styles.left, styles.headerText]}>
đnh danh đnh danh
</Text> </Text>
<Text style={[styles.cell, styles.headerText]}>Tên</Text> <View style={styles.cellWrapper}>
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
</View>
<Text style={[styles.cell, styles.right, styles.headerText]}> <Text style={[styles.cell, styles.right, styles.headerText]}>
Chức vụ Chức vụ
</Text> </Text>
@@ -119,7 +200,12 @@ const CrewListTable: React.FC = () => {
{data.map((item) => ( {data.map((item) => (
<View key={item.id} style={styles.row}> <View key={item.id} style={styles.row}>
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text> <Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
<Text style={[styles.cell]}>{item.ten}</Text> <TouchableOpacity
style={styles.cellWrapper}
onPress={() => handleCrewPress(item.id)}
>
<Text style={[styles.cell, styles.linkText]}>{item.ten}</Text>
</TouchableOpacity>
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text> <Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
</View> </View>
))} ))}
@@ -133,6 +219,13 @@ const CrewListTable: React.FC = () => {
<Text style={[styles.cell, styles.right]}></Text> <Text style={[styles.cell, styles.right]}></Text>
</View> </View>
</Animated.View> </Animated.View>
{/* Modal chi tiết thuyền viên */}
<CrewDetailModal
visible={modalVisible}
onClose={handleCloseModal}
crewData={selectedCrew}
/>
</View> </View>
); );
}; };

View File

@@ -1,30 +1,191 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native"; import { Animated, Text, TouchableOpacity, View } from "react-native";
import NetDetailModal from "./modal/NetDetailModal";
import styles from "./style/NetListTable.styles"; import styles from "./style/NetListTable.styles";
// --------------------------- // ---------------------------
// 🧩 Interface // 🧩 Interface
// --------------------------- // ---------------------------
interface FishCatch {
fish_species_id: number;
fish_name: string;
catch_number: number;
catch_unit: string;
fish_size: number;
fish_rarity: number;
fish_condition: string;
gear_usage: string;
}
interface NetItem { interface NetItem {
id: string; id: string;
stt: string; stt: string;
trangThai: string; trangThai: string;
thoiGianBatDau?: string;
thoiGianKetThuc?: string;
viTriHaThu?: string;
viTriThuLuoi?: string;
doSauHaThu?: string;
doSauThuLuoi?: string;
catchList?: FishCatch[];
ghiChu?: string;
} }
// --------------------------- // ---------------------------
// 🧵 Dữ liệu mẫu // 🧵 Dữ liệu mẫu
// --------------------------- // ---------------------------
const data: NetItem[] = [ const data: NetItem[] = [
{ id: "1", stt: "Mẻ 3", trangThai: "Đã hoàn thành" }, {
{ id: "2", stt: "Mẻ 2", trangThai: "Đã hoàn thành" }, id: "1",
{ id: "3", stt: "Mẻ 1", trangThai: "Đã hoàn thành" }, stt: "Mẻ 3",
trangThai: "Đã hoàn thành",
thoiGianBatDau: "08:00 - 01/11/2025",
thoiGianKetThuc: "12:30 - 01/11/2025",
viTriHaThu: "16°12'34\"N 107°56'12\"E",
viTriThuLuoi: "16°13'45\"N 107°57'23\"E",
doSauHaThu: "45m",
doSauThuLuoi: "48m",
catchList: [
{
fish_species_id: 3,
fish_name: "Cá chim trắng",
catch_number: 978,
catch_unit: "kg",
fish_size: 111,
fish_rarity: 2,
fish_condition: "Chết",
gear_usage: "",
},
{
fish_species_id: 13,
fish_name: "Cá song đỏ",
catch_number: 1061,
catch_unit: "kg",
fish_size: 154,
fish_rarity: 2,
fish_condition: "Còn sống",
gear_usage: "",
},
{
fish_species_id: 15,
fish_name: "Cá hồng",
catch_number: 613,
catch_unit: "kg",
fish_size: 199,
fish_rarity: 2,
fish_condition: "Còn sống",
gear_usage: "",
},
],
ghiChu: "Thời tiết tốt, sản lượng cao",
},
{
id: "2",
stt: "Mẻ 2",
trangThai: "Đã hoàn thành",
thoiGianBatDau: "14:00 - 31/10/2025",
thoiGianKetThuc: "18:45 - 31/10/2025",
viTriHaThu: "16°10'20\"N 107°54'30\"E",
viTriThuLuoi: "16°11'30\"N 107°55'40\"E",
doSauHaThu: "40m",
doSauThuLuoi: "42m",
catchList: [
{
fish_species_id: 2,
fish_name: "Cá nục",
catch_number: 1102,
catch_unit: "kg",
fish_size: 12,
fish_rarity: 1,
fish_condition: "Bị thương",
gear_usage: "",
},
{
fish_species_id: 11,
fish_name: "Cá ngừ đại dương",
catch_number: 828,
catch_unit: "kg",
fish_size: 120,
fish_rarity: 1,
fish_condition: "Chết",
gear_usage: "",
},
],
ghiChu: "Biển động nhẹ",
},
{
id: "3",
stt: "Mẻ 1",
trangThai: "Đã hoàn thành",
thoiGianBatDau: "06:30 - 31/10/2025",
thoiGianKetThuc: "11:00 - 31/10/2025",
viTriHaThu: "16°08'15\"N 107°52'45\"E",
viTriThuLuoi: "16°09'25\"N 107°53'55\"E",
doSauHaThu: "35m",
doSauThuLuoi: "38m",
catchList: [
{
fish_species_id: 6,
fish_name: "Cá mú trắng",
catch_number: 1620,
catch_unit: "kg",
fish_size: 75,
fish_rarity: 2,
fish_condition: "Chết",
gear_usage: "",
},
{
fish_species_id: 8,
fish_name: "Cá hồng phớn",
catch_number: 648,
catch_unit: "kg",
fish_size: 25,
fish_rarity: 3,
fish_condition: "Còn sống",
gear_usage: "Lưới rê",
},
{
fish_species_id: 9,
fish_name: "Cá hổ Napoleon",
catch_number: 1111,
catch_unit: "kg",
fish_size: 86,
fish_rarity: 4,
fish_condition: "Bị thương",
gear_usage: "Lưới rê",
},
{
fish_species_id: 17,
fish_name: "Cá nược",
catch_number: 1081,
catch_unit: "kg",
fish_size: 195,
fish_rarity: 1,
fish_condition: "Chết",
gear_usage: "",
},
{
fish_species_id: 18,
fish_name: "Cá đuối quạt",
catch_number: 1198,
catch_unit: "kg",
fish_size: 21,
fish_rarity: 4,
fish_condition: "Chết",
gear_usage: "Câu tay",
},
],
ghiChu: "Mẻ lưới đầu tiên, sản lượng tốt",
},
]; ];
const NetListTable: React.FC = () => { const NetListTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0); const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current; const animatedHeight = useRef(new Animated.Value(0)).current;
const [modalVisible, setModalVisible] = useState(false);
const [selectedNet, setSelectedNet] = useState<NetItem | null>(null);
const tongSoMe = data.length; const tongSoMe = data.length;
const handleToggle = () => { const handleToggle = () => {
@@ -37,6 +198,14 @@ const NetListTable: React.FC = () => {
setCollapsed((prev) => !prev); setCollapsed((prev) => !prev);
}; };
const handleStatusPress = (id: string) => {
const net = data.find((item) => item.id === id);
if (net) {
setSelectedNet(net);
setModalVisible(true);
}
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Header toggle */} {/* Header toggle */}
@@ -79,7 +248,9 @@ const NetListTable: React.FC = () => {
{/* Cột Trạng thái */} {/* Cột Trạng thái */}
<View style={[styles.cell, styles.statusContainer]}> <View style={[styles.cell, styles.statusContainer]}>
<View style={styles.statusDot} /> <View style={styles.statusDot} />
<Text style={styles.statusText}>{item.trangThai}</Text> <TouchableOpacity onPress={() => handleStatusPress(item.id)}>
<Text style={styles.statusText}>{item.trangThai}</Text>
</TouchableOpacity>
</View> </View>
</View> </View>
))} ))}
@@ -102,11 +273,20 @@ const NetListTable: React.FC = () => {
{/* Cột Trạng thái */} {/* Cột Trạng thái */}
<View style={[styles.cell, styles.statusContainer]}> <View style={[styles.cell, styles.statusContainer]}>
<View style={styles.statusDot} /> <View style={styles.statusDot} />
<Text style={styles.statusText}>{item.trangThai}</Text> <TouchableOpacity onPress={() => handleStatusPress(item.id)}>
<Text style={styles.statusText}>{item.trangThai}</Text>
</TouchableOpacity>
</View> </View>
</View> </View>
))} ))}
</Animated.View> </Animated.View>
{/* Modal chi tiết */}
<NetDetailModal
visible={modalVisible}
onClose={() => setModalVisible(false)}
netData={selectedNet}
/>
</View> </View>
); );
}; };

View File

@@ -1,6 +1,7 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native"; import { Animated, Text, TouchableOpacity, View } from "react-native";
import TripCostDetailModal from "./modal/TripCostDetailModal";
import styles from "./style/TripCostTable.styles"; import styles from "./style/TripCostTable.styles";
// --------------------------- // ---------------------------
@@ -60,6 +61,7 @@ const data: CostItem[] = [
const TripCostTable: React.FC = () => { const TripCostTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0); const [contentHeight, setContentHeight] = useState<number>(0);
const [modalVisible, setModalVisible] = useState(false);
const animatedHeight = useRef(new Animated.Value(0)).current; const animatedHeight = useRef(new Animated.Value(0)).current;
const tongCong = data.reduce((sum, item) => sum + item.tongChiPhi, 0); const tongCong = data.reduce((sum, item) => sum + item.tongChiPhi, 0);
@@ -73,6 +75,14 @@ const TripCostTable: React.FC = () => {
setCollapsed((prev) => !prev); setCollapsed((prev) => !prev);
}; };
const handleViewDetail = () => {
setModalVisible(true);
};
const handleCloseModal = () => {
setModalVisible(false);
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<TouchableOpacity <TouchableOpacity
@@ -140,6 +150,14 @@ const TripCostTable: React.FC = () => {
{tongCong.toLocaleString()} {tongCong.toLocaleString()}
</Text> </Text>
</View> </View>
{/* View Detail Button */}
<TouchableOpacity
style={styles.viewDetailButton}
onPress={handleViewDetail}
>
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
</TouchableOpacity>
</View> </View>
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}> <Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
@@ -170,7 +188,22 @@ const TripCostTable: React.FC = () => {
{tongCong.toLocaleString()} {tongCong.toLocaleString()}
</Text> </Text>
</View> </View>
{/* View Detail Button */}
<TouchableOpacity
style={styles.viewDetailButton}
onPress={handleViewDetail}
>
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
</TouchableOpacity>
</Animated.View> </Animated.View>
{/* Modal */}
<TripCostDetailModal
visible={modalVisible}
onClose={handleCloseModal}
data={data}
/>
</View> </View>
); );
}; };

View File

@@ -0,0 +1,91 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import React from "react";
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/CrewDetailModal.styles";
// ---------------------------
// 🧩 Interface
// ---------------------------
interface CrewMember {
id: string;
maDinhDanh: string;
ten: string;
chucVu: string;
ngaySinh?: string;
cccd?: string;
soDienThoai?: string;
diaChi?: string;
ngayVaoLam?: string;
trinhDoChuyenMon?: string;
bangCap?: string;
tinhTrang?: string;
}
interface CrewDetailModalProps {
visible: boolean;
onClose: () => void;
crewData: CrewMember | null;
}
// ---------------------------
// 👤 Component Modal
// ---------------------------
const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
visible,
onClose,
crewData,
}) => {
if (!crewData) return null;
const infoItems = [
{ label: "Mã định danh", value: crewData.maDinhDanh },
{ label: "Họ và tên", value: crewData.ten },
{ label: "Chức vụ", value: crewData.chucVu },
{ label: "Ngày sinh", value: crewData.ngaySinh || "Chưa cập nhật" },
{ label: "CCCD/CMND", value: crewData.cccd || "Chưa cập nhật" },
{ label: "Số điện thoại", value: crewData.soDienThoai || "Chưa cập nhật" },
{ label: "Địa chỉ", value: crewData.diaChi || "Chưa cập nhật" },
{ label: "Ngày vào làm", value: crewData.ngayVaoLam || "Chưa cập nhật" },
{
label: "Trình độ chuyên môn",
value: crewData.trinhDoChuyenMon || "Chưa cập nhật",
},
{ label: "Bằng cấp", value: crewData.bangCap || "Chưa cập nhật" },
{ label: "Tình trạng", value: crewData.tinhTrang || "Đang làm việc" },
];
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Thông tin thuyền viên</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<View style={styles.closeIconButton}>
<IconSymbol name="xmark" size={28} color="#fff" />
</View>
</TouchableOpacity>
</View>
{/* Content */}
<ScrollView style={styles.content}>
<View style={styles.infoCard}>
{infoItems.map((item, index) => (
<View key={index} style={styles.infoRow}>
<Text style={styles.infoLabel}>{item.label}</Text>
<Text style={styles.infoValue}>{item.value}</Text>
</View>
))}
</View>
</ScrollView>
</View>
</Modal>
);
};
export default CrewDetailModal;

View File

@@ -0,0 +1,515 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useState } from "react";
import {
Modal,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import styles from "./style/NetDetailModal.styles";
// ---------------------------
// 🧩 Interface
// ---------------------------
interface FishCatch {
fish_species_id: number;
fish_name: string;
catch_number: number;
catch_unit: string;
fish_size: number;
fish_rarity: number;
fish_condition: string;
gear_usage: string;
}
interface NetDetail {
id: string;
stt: string;
trangThai: string;
thoiGianBatDau?: string;
thoiGianKetThuc?: string;
viTriHaThu?: string;
viTriThuLuoi?: string;
doSauHaThu?: string;
doSauThuLuoi?: string;
catchList?: FishCatch[];
ghiChu?: string;
}
interface NetDetailModalProps {
visible: boolean;
onClose: () => void;
netData: NetDetail | null;
}
// ---------------------------
// 🧵 Component Modal
// ---------------------------
const NetDetailModal: React.FC<NetDetailModalProps> = ({
visible,
onClose,
netData,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editableCatchList, setEditableCatchList] = useState<FishCatch[]>([]);
const [selectedFishIndex, setSelectedFishIndex] = useState<number | null>(
null
);
const [selectedUnitIndex, setSelectedUnitIndex] = useState<number | null>(
null
);
const [selectedConditionIndex, setSelectedConditionIndex] = useState<
number | null
>(null);
// Khởi tạo dữ liệu khi netData thay đổi
React.useEffect(() => {
if (netData?.catchList) {
setEditableCatchList(netData.catchList);
}
}, [netData]);
if (!netData) return null;
const isCompleted = netData.trangThai === "Đã hoàn thành";
// Danh sách tên cá có sẵn
const fishNameOptions = [
"Cá chim trắng",
"Cá song đỏ",
"Cá hồng",
"Cá nục",
"Cá ngừ đại dương",
"Cá mú trắng",
"Cá hồng phớn",
"Cá hổ Napoleon",
"Cá nược",
"Cá đuối quạt",
];
// Danh sách đơn vị
const unitOptions = ["kg", "con", "tấn"];
// Danh sách tình trạng
const conditionOptions = ["Còn sống", "Chết", "Bị thương"];
const handleEdit = () => {
setIsEditing(!isEditing);
};
const handleSave = () => {
setIsEditing(false);
// TODO: Save data to backend
console.log("Saved catch list:", editableCatchList);
};
const handleCancel = () => {
setIsEditing(false);
setEditableCatchList(netData.catchList || []);
};
const updateCatchItem = (
index: number,
field: keyof FishCatch,
value: string | number
) => {
setEditableCatchList((prev) =>
prev.map((item, i) => {
if (i === index) {
const updatedItem = { ...item };
if (
field === "catch_number" ||
field === "fish_size" ||
field === "fish_rarity"
) {
updatedItem[field] = Number(value) || 0;
} else {
updatedItem[field] = value as never;
}
return updatedItem;
}
return item;
})
);
};
const totalCatch = editableCatchList.reduce(
(sum, item) => sum + item.catch_number,
0
);
const infoItems = [
{ label: "Số thứ tự", value: netData.stt },
{
label: "Trạng thái",
value: netData.trangThai,
isStatus: true,
},
{
label: "Thời gian bắt đầu",
value: netData.thoiGianBatDau || "Chưa cập nhật",
},
{
label: "Thời gian kết thúc",
value: netData.thoiGianKetThuc || "Chưa cập nhật",
},
{
label: "Vị trí hạ thu",
value: netData.viTriHaThu || "Chưa cập nhật",
},
{
label: "Vị trí thu lưới",
value: netData.viTriThuLuoi || "Chưa cập nhật",
},
{
label: "Độ sâu hạ thu",
value: netData.doSauHaThu || "Chưa cập nhật",
},
{
label: "Độ sâu thu lưới",
value: netData.doSauThuLuoi || "Chưa cập nhật",
},
];
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Chi tiết mẻ lưới</Text>
<View style={styles.headerButtons}>
{isEditing ? (
<>
<TouchableOpacity
onPress={handleCancel}
style={styles.cancelButton}
>
<Text style={styles.cancelButtonText}>Hủy</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
style={styles.saveButton}
>
<Text style={styles.saveButtonText}>Lưu</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity onPress={handleEdit} style={styles.editButton}>
<View style={styles.editIconButton}>
<IconSymbol
name="pencil"
size={28}
color="#fff"
weight="heavy"
/>
</View>
</TouchableOpacity>
)}
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<View style={styles.closeIconButton}>
<IconSymbol name="xmark" size={28} color="#fff" />
</View>
</TouchableOpacity>
</View>
</View>
{/* Content */}
<ScrollView style={styles.content}>
{/* Thông tin chung */}
<View style={styles.infoCard}>
{infoItems.map((item, index) => (
<View key={index} style={styles.infoRow}>
<Text style={styles.infoLabel}>{item.label}</Text>
{item.isStatus ? (
<View
style={[
styles.statusBadge,
isCompleted
? styles.statusBadgeCompleted
: styles.statusBadgeInProgress,
]}
>
<Text
style={[
styles.statusBadgeText,
isCompleted
? styles.statusBadgeTextCompleted
: styles.statusBadgeTextInProgress,
]}
>
{item.value}
</Text>
</View>
) : (
<Text style={styles.infoValue}>{item.value}</Text>
)}
</View>
))}
</View>
{/* Danh sách cá bắt được */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Danh sách bắt đưc</Text>
<Text style={styles.totalCatchText}>
Tổng: {totalCatch.toLocaleString()} kg
</Text>
</View>
{editableCatchList.map((fish, index) => (
<View key={index} style={styles.fishCard}>
{/* Tên cá - Select */}
<View style={[styles.fieldGroup, { zIndex: 1000 - index }]}>
<Text style={styles.label}>Tên </Text>
{isEditing ? (
<View style={{ zIndex: 1000 - index }}>
<TouchableOpacity
style={styles.selectButton}
onPress={() =>
setSelectedFishIndex(
selectedFishIndex === index ? null : index
)
}
>
<Text style={styles.selectButtonText}>
{fish.fish_name}
</Text>
<IconSymbol
name={
selectedFishIndex === index
? "chevron.up"
: "chevron.down"
}
size={16}
color="#666"
/>
</TouchableOpacity>
{selectedFishIndex === index && (
<ScrollView
style={styles.optionsList}
nestedScrollEnabled={true}
>
{fishNameOptions.map((option, optIndex) => (
<TouchableOpacity
key={optIndex}
style={styles.optionItem}
onPress={() => {
updateCatchItem(index, "fish_name", option);
setSelectedFishIndex(null);
}}
>
<Text style={styles.optionText}>{option}</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
</View>
) : (
<Text style={styles.infoValue}>{fish.fish_name}</Text>
)}
</View>
{/* Số lượng & Đơn vị */}
<View style={styles.rowGroup}>
<View style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}>
<Text style={styles.label}>Số lượng</Text>
{isEditing ? (
<TextInput
style={styles.input}
value={String(fish.catch_number)}
onChangeText={(value) =>
updateCatchItem(index, "catch_number", value)
}
keyboardType="numeric"
placeholder="0"
/>
) : (
<Text style={styles.infoValue}>{fish.catch_number}</Text>
)}
</View>
<View
style={[
styles.fieldGroup,
{ flex: 1, marginLeft: 8, zIndex: 900 - index },
]}
>
<Text style={styles.label}>Đơn vị</Text>
{isEditing ? (
<View style={{ zIndex: 900 - index }}>
<TouchableOpacity
style={styles.selectButton}
onPress={() =>
setSelectedUnitIndex(
selectedUnitIndex === index ? null : index
)
}
>
<Text style={styles.selectButtonText}>
{fish.catch_unit}
</Text>
<IconSymbol
name={
selectedUnitIndex === index
? "chevron.up"
: "chevron.down"
}
size={16}
color="#666"
/>
</TouchableOpacity>
{selectedUnitIndex === index && (
<ScrollView
style={styles.optionsList}
nestedScrollEnabled={true}
>
{unitOptions.map((option, optIndex) => (
<TouchableOpacity
key={optIndex}
style={styles.optionItem}
onPress={() => {
updateCatchItem(index, "catch_unit", option);
setSelectedUnitIndex(null);
}}
>
<Text style={styles.optionText}>{option}</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
</View>
) : (
<Text style={styles.infoValue}>{fish.catch_unit}</Text>
)}
</View>
</View>
{/* Kích thước & Độ hiếm */}
<View style={styles.rowGroup}>
<View style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}>
<Text style={styles.label}>Kích thước (cm)</Text>
{isEditing ? (
<TextInput
style={styles.input}
value={String(fish.fish_size)}
onChangeText={(value) =>
updateCatchItem(index, "fish_size", value)
}
keyboardType="numeric"
placeholder="0"
/>
) : (
<Text style={styles.infoValue}>{fish.fish_size} cm</Text>
)}
</View>
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Đ hiếm</Text>
{isEditing ? (
<TextInput
style={styles.input}
value={String(fish.fish_rarity)}
onChangeText={(value) =>
updateCatchItem(index, "fish_rarity", value)
}
keyboardType="numeric"
placeholder="1-5"
/>
) : (
<Text style={styles.infoValue}>{fish.fish_rarity}</Text>
)}
</View>
</View>
{/* Tình trạng */}
<View style={[styles.fieldGroup, { zIndex: 800 - index }]}>
<Text style={styles.label}>Tình trạng</Text>
{isEditing ? (
<View style={{ zIndex: 800 - index }}>
<TouchableOpacity
style={styles.selectButton}
onPress={() =>
setSelectedConditionIndex(
selectedConditionIndex === index ? null : index
)
}
>
<Text style={styles.selectButtonText}>
{fish.fish_condition}
</Text>
<IconSymbol
name={
selectedConditionIndex === index
? "chevron.up"
: "chevron.down"
}
size={16}
color="#666"
/>
</TouchableOpacity>
{selectedConditionIndex === index && (
<ScrollView
style={styles.optionsStatusFishList}
nestedScrollEnabled={true}
>
{conditionOptions.map((option, optIndex) => (
<TouchableOpacity
key={optIndex}
style={styles.optionItem}
onPress={() => {
updateCatchItem(index, "fish_condition", option);
setSelectedConditionIndex(null);
}}
>
<Text style={styles.optionText}>{option}</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
</View>
) : (
<Text style={styles.infoValue}>{fish.fish_condition}</Text>
)}
</View>
{/* Ngư cụ sử dụng */}
<View style={styles.fieldGroup}>
<Text style={styles.label}>Ngư cụ sử dụng</Text>
{isEditing ? (
<TextInput
style={styles.input}
value={fish.gear_usage}
onChangeText={(value) =>
updateCatchItem(index, "gear_usage", value)
}
placeholder="Nhập ngư cụ..."
/>
) : (
<Text style={styles.infoValue}>
{fish.gear_usage || "Không có"}
</Text>
)}
</View>
</View>
))}
{/* Ghi chú */}
{netData.ghiChu && (
<View style={styles.infoCard}>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Ghi chú</Text>
<Text style={styles.infoValue}>{netData.ghiChu}</Text>
</View>
</View>
)}
</ScrollView>
</View>
</Modal>
);
};
export default NetDetailModal;

View File

@@ -0,0 +1,222 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useState } from "react";
import {
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import styles from "./style/TripCostDetailModal.styles";
// ---------------------------
// 🧩 Interface
// ---------------------------
interface CostItem {
id: string;
loai: string;
soLuong: number;
donVi: string;
chiPhi: number;
tongChiPhi: number;
}
interface TripCostDetailModalProps {
visible: boolean;
onClose: () => void;
data: CostItem[];
}
// ---------------------------
// 💰 Component Modal
// ---------------------------
const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
visible,
onClose,
data,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editableData, setEditableData] = useState<CostItem[]>(data);
const tongCong = editableData.reduce((sum, item) => sum + item.tongChiPhi, 0);
const handleEdit = () => {
setIsEditing(!isEditing);
};
const handleSave = () => {
setIsEditing(false);
// TODO: Save data to backend
console.log("Saved data:", editableData);
};
const handleCancel = () => {
setIsEditing(false);
setEditableData(data); // Reset to original data
};
const updateItem = (id: string, field: keyof CostItem, value: string) => {
setEditableData((prev) =>
prev.map((item) => {
if (item.id === id) {
const numValue =
field === "loai" || field === "donVi" ? value : Number(value) || 0;
const updated = { ...item, [field]: numValue };
// Recalculate tongChiPhi
if (field === "soLuong" || field === "chiPhi") {
updated.tongChiPhi = updated.soLuong * updated.chiPhi;
}
return updated;
}
return item;
})
);
};
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={60}
>
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Chi tiết chi phí chuyến đi</Text>
<View style={styles.headerButtons}>
{isEditing ? (
<>
<TouchableOpacity
onPress={handleCancel}
style={styles.cancelButton}
>
<Text style={styles.cancelButtonText}>Hủy</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
style={styles.saveButton}
>
<Text style={styles.saveButtonText}>Lưu</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity
onPress={handleEdit}
style={styles.editButton}
>
<View style={styles.editIconButton}>
<IconSymbol
name="pencil"
size={28}
color="#fff"
weight="heavy"
/>
</View>
</TouchableOpacity>
)}
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<View style={styles.closeIconButton}>
<IconSymbol name="xmark" size={28} color="#fff" />
</View>
</TouchableOpacity>
</View>
</View>
{/* Content */}
<ScrollView style={styles.content}>
{editableData.map((item) => (
<View key={item.id} style={styles.itemCard}>
{/* Loại */}
<View style={styles.fieldGroup}>
<Text style={styles.label}>Loại chi phí</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={item.loai}
onChangeText={(value) => updateItem(item.id, "loai", value)}
editable={isEditing}
placeholder="Nhập loại chi phí"
/>
</View>
{/* Số lượng & Đơn vị */}
<View style={styles.rowGroup}>
<View
style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}
>
<Text style={styles.label}>Số lượng</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={String(item.soLuong)}
onChangeText={(value) =>
updateItem(item.id, "soLuong", value)
}
editable={isEditing}
keyboardType="numeric"
placeholder="0"
/>
</View>
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Đơn vị</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={item.donVi}
onChangeText={(value) =>
updateItem(item.id, "donVi", value)
}
editable={isEditing}
placeholder="kg, lít..."
/>
</View>
</View>
{/* Chi phí/đơn vị */}
<View style={styles.fieldGroup}>
<Text style={styles.label}>Chi phí/đơn vị (VNĐ)</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={String(item.chiPhi)}
onChangeText={(value) =>
updateItem(item.id, "chiPhi", value)
}
editable={isEditing}
keyboardType="numeric"
placeholder="0"
/>
</View>
{/* Tổng chi phí */}
<View style={styles.fieldGroup}>
<Text style={styles.label}>Tổng chi phí</Text>
<View style={styles.totalContainer}>
<Text style={styles.totalText}>
{item.tongChiPhi.toLocaleString()} VNĐ
</Text>
</View>
</View>
</View>
))}
{/* Footer Total */}
<View style={styles.footerTotal}>
<Text style={styles.footerLabel}>Tổng cộng</Text>
<Text style={styles.footerAmount}>
{tongCong.toLocaleString()} VNĐ
</Text>
</View>
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
export default TripCostDetailModal;

View File

@@ -0,0 +1,69 @@
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 30,
paddingBottom: 16,
backgroundColor: "#fff",
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
title: {
fontSize: 22,
fontWeight: "700",
color: "#000",
flex: 1,
},
closeButton: {
padding: 4,
},
closeIconButton: {
backgroundColor: "#FF3B30",
borderRadius: 10,
padding: 10,
justifyContent: "center",
alignItems: "center",
},
content: {
flex: 1,
padding: 16,
marginBottom: 15,
},
infoCard: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 35,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
infoRow: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
infoLabel: {
fontSize: 13,
fontWeight: "600",
color: "#666",
marginBottom: 6,
},
infoValue: {
fontSize: 16,
color: "#000",
fontWeight: "500",
},
});
export default styles;

View File

@@ -0,0 +1,236 @@
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 30,
paddingBottom: 16,
backgroundColor: "#fff",
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
title: {
fontSize: 22,
fontWeight: "700",
color: "#000",
flex: 1,
},
closeButton: {
padding: 4,
},
closeIconButton: {
backgroundColor: "#FF3B30",
borderRadius: 10,
padding: 10,
justifyContent: "center",
alignItems: "center",
},
content: {
flex: 1,
padding: 16,
marginBottom: 15,
},
infoCard: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 35,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
infoRow: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
infoLabel: {
fontSize: 13,
fontWeight: "600",
color: "#666",
marginBottom: 6,
},
infoValue: {
fontSize: 16,
color: "#000",
fontWeight: "500",
},
statusBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
alignSelf: "flex-start",
},
statusBadgeCompleted: {
backgroundColor: "#e8f5e9",
},
statusBadgeInProgress: {
backgroundColor: "#fff3e0",
},
statusBadgeText: {
fontSize: 14,
fontWeight: "600",
},
statusBadgeTextCompleted: {
color: "#2e7d32",
},
statusBadgeTextInProgress: {
color: "#f57c00",
},
headerButtons: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
editButton: {
padding: 4,
},
editIconButton: {
backgroundColor: "#007AFF",
borderRadius: 10,
padding: 10,
justifyContent: "center",
alignItems: "center",
},
cancelButton: {
paddingHorizontal: 12,
paddingVertical: 6,
},
cancelButtonText: {
color: "#007AFF",
fontSize: 16,
fontWeight: "600",
},
saveButton: {
backgroundColor: "#007AFF",
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 6,
},
saveButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 16,
marginBottom: 12,
paddingHorizontal: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: "700",
color: "#000",
},
totalCatchText: {
fontSize: 16,
fontWeight: "600",
color: "#007AFF",
},
fishCard: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
fieldGroup: {
marginBottom: 12,
position: "relative",
},
rowGroup: {
flexDirection: "row",
marginBottom: 12,
},
label: {
fontSize: 13,
fontWeight: "600",
color: "#666",
marginBottom: 6,
},
input: {
borderWidth: 1,
borderColor: "#007AFF",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: "#000",
backgroundColor: "#fff",
},
selectButton: {
borderWidth: 1,
borderColor: "#007AFF",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: "#fff",
},
selectButtonText: {
fontSize: 15,
color: "#000",
},
optionsList: {
position: "absolute",
top: 46,
left: 0,
right: 0,
borderWidth: 1,
borderColor: "#007AFF",
borderRadius: 8,
marginTop: 4,
backgroundColor: "#fff",
maxHeight: 200,
zIndex: 1000,
elevation: 5,
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
},
optionItem: {
paddingHorizontal: 12,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
optionText: {
fontSize: 15,
color: "#000",
},
optionsStatusFishList: {
borderWidth: 1,
borderColor: "#007AFF",
borderRadius: 8,
marginTop: 4,
backgroundColor: "#fff",
maxHeight: 200,
zIndex: 1000,
elevation: 5,
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
},
});
export default styles;

View File

@@ -0,0 +1,153 @@
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
closeIconButton: {
backgroundColor: "#FF3B30",
borderRadius: 10,
padding: 10,
justifyContent: "center",
alignItems: "center",
},
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 30,
paddingBottom: 16,
backgroundColor: "#fff",
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
title: {
fontSize: 22,
fontWeight: "700",
color: "#000",
flex: 1,
},
headerButtons: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
editButton: {
padding: 4,
},
editIconButton: {
backgroundColor: "#007AFF",
borderRadius: 10,
padding: 10,
justifyContent: "center",
alignItems: "center",
},
cancelButton: {
paddingHorizontal: 12,
paddingVertical: 6,
},
cancelButtonText: {
color: "#007AFF",
fontSize: 16,
fontWeight: "600",
},
saveButton: {
backgroundColor: "#007AFF",
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 6,
},
saveButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
closeButton: {
padding: 4,
},
content: {
flex: 1,
padding: 16,
},
itemCard: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
fieldGroup: {
marginBottom: 12,
},
rowGroup: {
flexDirection: "row",
marginBottom: 12,
},
label: {
fontSize: 13,
fontWeight: "600",
color: "#666",
marginBottom: 6,
},
input: {
borderWidth: 1,
borderColor: "#007AFF",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: "#000",
backgroundColor: "#fff",
},
inputDisabled: {
borderColor: "#ddd",
backgroundColor: "#f9f9f9",
color: "#666",
},
totalContainer: {
backgroundColor: "#fff5e6",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderWidth: 1,
borderColor: "#ffd699",
},
totalText: {
fontSize: 16,
fontWeight: "700",
color: "#ff6600",
},
footerTotal: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 20,
marginTop: 8,
marginBottom: 50,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
},
footerLabel: {
fontSize: 18,
fontWeight: "700",
color: "#007bff",
},
footerAmount: {
fontSize: 20,
fontWeight: "700",
color: "#ff6600",
},
});
export default styles;

View File

@@ -47,6 +47,11 @@ export default StyleSheet.create({
color: "#111", color: "#111",
textAlign: "center", textAlign: "center",
}, },
cellWrapper: {
flex: 1.5,
justifyContent: "center",
alignItems: "center",
},
left: { left: {
textAlign: "center", textAlign: "center",
}, },
@@ -64,4 +69,8 @@ export default StyleSheet.create({
color: "#ff6600", color: "#ff6600",
fontWeight: "800", fontWeight: "800",
}, },
linkText: {
color: "#007AFF",
textDecorationLine: "underline",
},
}); });

View File

@@ -4,13 +4,13 @@ export default StyleSheet.create({
container: { container: {
width: "100%", width: "100%",
backgroundColor: "#fff", backgroundColor: "#fff",
borderRadius: 10, borderRadius: 12,
padding: 12, padding: 16,
marginVertical: 10, marginVertical: 10,
borderWidth: 1, borderWidth: 1,
borderColor: "#eee", borderColor: "#eee",
shadowColor: "#000", shadowColor: "#000",
shadowOpacity: 0.05, shadowOpacity: 0.1,
shadowRadius: 4, shadowRadius: 4,
elevation: 1, elevation: 1,
}, },
@@ -34,7 +34,7 @@ export default StyleSheet.create({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
paddingVertical: 8, paddingVertical: 8,
borderBottomWidth: 0.6, borderBottomWidth: 0.5,
borderBottomColor: "#eee", borderBottomColor: "#eee",
}, },
tableHeader: { tableHeader: {
@@ -52,7 +52,7 @@ export default StyleSheet.create({
flex: 0.3, flex: 0.3,
fontSize: 15, fontSize: 15,
color: "#111", color: "#111",
textAlign: "left", textAlign: "center",
paddingLeft: 10, paddingLeft: 10,
}, },
headerText: { headerText: {
@@ -72,6 +72,7 @@ export default StyleSheet.create({
}, },
statusText: { statusText: {
fontSize: 15, fontSize: 15,
color: "#111", color: "#4a90e2",
textDecorationLine: "underline",
}, },
}); });

View File

@@ -56,6 +56,17 @@ const styles = StyleSheet.create({
color: "#ff6600", color: "#ff6600",
fontWeight: "700", fontWeight: "700",
}, },
viewDetailButton: {
marginTop: 12,
paddingVertical: 8,
alignItems: "center",
},
viewDetailText: {
color: "#007AFF",
fontSize: 15,
fontWeight: "600",
textDecorationLine: "underline",
},
}); });
export default styles; export default styles;

View File

@@ -28,6 +28,8 @@ const MAPPING = {
"exclamationmark.triangle.fill": "warning", "exclamationmark.triangle.fill": "warning",
"book.closed.fill": "book", "book.closed.fill": "book",
"dot.radiowaves.left.and.right": "sensors", "dot.radiowaves.left.and.right": "sensors",
xmark: "close",
pencil: "edit",
} as IconMapping; } as IconMapping;
/** /**

View File

@@ -15,6 +15,12 @@ export const DARK_THEME = "dark";
export const ROUTE_LOGIN = "/login"; export const ROUTE_LOGIN = "/login";
export const ROUTE_HOME = "/map"; export const ROUTE_HOME = "/map";
export const ROUTE_TRIP = "/trip"; export const ROUTE_TRIP = "/trip";
// Event Emitters
export const EVENT_GPS_DATA = "GPS_DATA_EVENT";
export const EVENT_ALARM_DATA = "ALARM_DATA_EVENT";
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
// Entity Contants // Entity Contants
export const ENTITY = { export const ENTITY = {

View File

@@ -1,10 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/ // https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config'); import expoConfig from "eslint-config-expo/flat";
const expoConfig = require('eslint-config-expo/flat'); import { defineConfig } from "eslint/config";
module.exports = defineConfig([ export default defineConfig([
expoConfig, expoConfig,
{ {
ignores: ['dist/*'], ignores: ["dist/*"],
}, },
]); ]);

32
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"axios": "^1.13.1", "axios": "^1.13.1",
"eventemitter3": "^5.0.1",
"expo": "~54.0.20", "expo": "~54.0.20",
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
@@ -31,6 +32,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
"react-native-maps": "^1.20.1", "react-native-maps": "^1.20.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
@@ -6316,6 +6318,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/exec-async": { "node_modules/exec-async": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
@@ -10817,7 +10825,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -10829,7 +10836,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
@@ -11345,6 +11351,15 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-iphone-x-helper": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz",
"integrity": "sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg==",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.42.0"
}
},
"node_modules/react-native-is-edge-to-edge": { "node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
@@ -11355,6 +11370,19 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-keyboard-aware-scroll-view": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/react-native-keyboard-aware-scroll-view/-/react-native-keyboard-aware-scroll-view-0.9.5.tgz",
"integrity": "sha512-XwfRn+T/qBH9WjTWIBiJD2hPWg0yJvtaEw6RtPCa5/PYHabzBaWxYBOl0usXN/368BL1XktnZPh8C2lmTpOREA==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.6.2",
"react-native-iphone-x-helper": "^1.0.3"
},
"peerDependencies": {
"react-native": ">=0.48.4"
}
},
"node_modules/react-native-maps": { "node_modules/react-native-maps": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz", "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz",

View File

@@ -17,6 +17,7 @@
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"axios": "^1.13.1", "axios": "^1.13.1",
"eventemitter3": "^5.0.1",
"expo": "~54.0.20", "expo": "~54.0.20",
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
@@ -34,6 +35,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
"react-native-maps": "^1.20.1", "react-native-maps": "^1.20.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",

164
services/device_events.ts Normal file
View File

@@ -0,0 +1,164 @@
import {
AUTO_REFRESH_INTERVAL,
EVENT_ALARM_DATA,
EVENT_BANZONE_DATA,
EVENT_ENTITY_DATA,
EVENT_GPS_DATA,
EVENT_TRACK_POINTS_DATA,
} from "@/constants";
import {
queryAlarm,
queryEntities,
queryGpsData,
queryTrackPoints,
} from "@/controller/DeviceController";
import { queryBanzones } from "@/controller/MapController";
import eventBus from "@/utils/eventBus";
const intervals: {
gps: ReturnType<typeof setInterval> | null;
alarm: ReturnType<typeof setInterval> | null;
entities: ReturnType<typeof setInterval> | null;
trackPoints: ReturnType<typeof setInterval> | null;
banzones: ReturnType<typeof setInterval> | null;
} = {
gps: null,
alarm: null,
entities: null,
trackPoints: null,
banzones: null,
};
export function getGpsEventBus() {
if (intervals.gps) return;
console.log("Starting GPS poller");
const getGpsData = async () => {
try {
console.log("GPS: fetching data...");
const resp = await queryGpsData();
if (resp && resp.data) {
console.log("GPS: emitting data", resp.data);
eventBus.emit(EVENT_GPS_DATA, resp.data);
} else {
console.log("GPS: no data returned");
}
} catch (err) {
console.error("GPS: fetch error", err);
}
};
// Run immediately once, then schedule
getGpsData();
intervals.gps = setInterval(() => {
getGpsData();
}, AUTO_REFRESH_INTERVAL);
}
export function getAlarmEventBus() {
if (intervals.alarm) return;
console.log("Goi ham get Alarm");
const getAlarmData = async () => {
try {
console.log("Alarm: fetching data...");
const resp = await queryAlarm();
if (resp && resp.data) {
console.log(
"Alarm: emitting data",
resp.data?.alarms?.length ?? resp.data
);
eventBus.emit(EVENT_ALARM_DATA, resp.data);
} else {
console.log("Alarm: no data returned");
}
} catch (err) {
console.error("Alarm: fetch error", err);
}
};
getAlarmData();
intervals.alarm = setInterval(() => {
getAlarmData();
}, AUTO_REFRESH_INTERVAL);
}
export function getEntitiesEventBus() {
if (intervals.entities) return;
console.log("Goi ham get Entities");
const getEntitiesData = async () => {
try {
console.log("Entities: fetching data...");
const resp = await queryEntities();
if (resp && resp.length > 0) {
console.log("Entities: emitting", resp.length);
eventBus.emit(EVENT_ENTITY_DATA, resp);
} else {
console.log("Entities: no data returned");
}
} catch (err) {
console.error("Entities: fetch error", err);
}
};
getEntitiesData();
intervals.entities = setInterval(() => {
getEntitiesData();
}, AUTO_REFRESH_INTERVAL);
}
export function getTrackPointsEventBus() {
if (intervals.trackPoints) return;
console.log("Goi ham get Track Points");
const getTrackPointsData = async () => {
try {
console.log("TrackPoints: fetching data...");
const resp = await queryTrackPoints();
if (resp && resp.data && resp.data.length > 0) {
console.log("TrackPoints: emitting", resp.data.length);
eventBus.emit(EVENT_TRACK_POINTS_DATA, resp.data);
} else {
console.log("TrackPoints: no data returned");
}
} catch (err) {
console.error("TrackPoints: fetch error", err);
}
};
getTrackPointsData();
intervals.trackPoints = setInterval(() => {
getTrackPointsData();
}, AUTO_REFRESH_INTERVAL);
}
export function getBanzonesEventBus() {
if (intervals.banzones) return;
const getBanzonesData = async () => {
try {
console.log("Banzones: fetching data...");
const resp = await queryBanzones();
if (resp && resp.data && resp.data.length > 0) {
console.log("Banzones: emitting", resp.data.length);
eventBus.emit(EVENT_BANZONE_DATA, resp.data);
} else {
console.log("Banzones: no data returned");
}
} catch (err) {
console.error("Banzones: fetch error", err);
}
};
getBanzonesData();
intervals.banzones = setInterval(() => {
getBanzonesData();
}, AUTO_REFRESH_INTERVAL * 60);
}
export function stopEvents() {
Object.keys(intervals).forEach((k) => {
const key = k as keyof typeof intervals;
if (intervals[key]) {
clearInterval(intervals[key] as ReturnType<typeof setInterval>);
intervals[key] = null;
}
});
}

5
utils/eventBus.ts Normal file
View File

@@ -0,0 +1,5 @@
import EventEmitter from "eventemitter3";
const eventBus = new EventEmitter();
export default eventBus;