Compare commits

...

4 Commits

Author SHA1 Message Date
0672f8adf9 update interface, diary 2025-12-04 15:54:49 +07:00
Tran Anh Tuan
4d60ce279e thêm FormSearch trong map 2025-12-04 15:02:53 +07:00
Tran Anh Tuan
42028eafc3 xóa task 2025-12-03 16:22:46 +07:00
Tran Anh Tuan
22a3b591c6 hiển thị thuyền thông tin tàu 2025-12-03 16:22:25 +07:00
28 changed files with 2489 additions and 371 deletions

View File

@@ -60,7 +60,7 @@ export default function TabLayout() {
options={{ options={{
title: t("navigation.manager"), title: t("navigation.manager"),
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="square.stack.3d.up" color={color} /> <IconSymbol size={28} name="square.stack.3d.up.fill" color={color} />
), ),
}} }}
/> />

View File

@@ -1,5 +1,12 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; 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";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import SearchBar from "@/components/diary/SearchBar"; import SearchBar from "@/components/diary/SearchBar";
@@ -7,6 +14,7 @@ import FilterButton from "@/components/diary/FilterButton";
import TripCard from "@/components/diary/TripCard"; import TripCard from "@/components/diary/TripCard";
import FilterModal, { FilterValues } from "@/components/diary/FilterModal"; import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
import { MOCK_TRIPS } from "@/components/diary/mockData"; import { MOCK_TRIPS } from "@/components/diary/mockData";
import { useThings } from "@/state/use-thing";
export default function diary() { export default function diary() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
@@ -16,6 +24,23 @@ export default function diary() {
startDate: null, startDate: null,
endDate: null, endDate: null,
}); });
// Body things (đang fix cứng)
const payloadThings: Model.SearchThingBody = {
offset: 0,
limit: 200,
order: "name",
dir: "asc",
metadata: {
not_empty: "ship_name, ship_reg_number",
},
};
// Gọi API things
const { things, getThings } = useThings();
useEffect(() => {
getThings(payloadThings);
}, []);
console.log(things);
// Filter trips based on search text and filters // Filter trips based on search text and filters
const filteredTrips = MOCK_TRIPS.filter((trip) => { const filteredTrips = MOCK_TRIPS.filter((trip) => {
@@ -73,6 +98,31 @@ export default function diary() {
console.log("Trip pressed:", tripId); console.log("Trip pressed:", tripId);
}; };
const handleViewTrip = (tripId: string) => {
console.log("View trip:", tripId);
// TODO: Navigate to trip detail view
};
const handleEditTrip = (tripId: string) => {
console.log("Edit trip:", tripId);
// TODO: Navigate to trip edit screen
};
const handleViewTeam = (tripId: string) => {
console.log("View team:", tripId);
// TODO: Navigate to team management
};
const handleSendTrip = (tripId: string) => {
console.log("Send trip:", tripId);
// TODO: Send trip for approval
};
const handleDeleteTrip = (tripId: string) => {
console.log("Delete trip:", tripId);
// TODO: Show confirmation dialog and delete trip
};
return ( return (
<SafeAreaView style={styles.safeArea}> <SafeAreaView style={styles.safeArea}>
<View style={styles.container}> <View style={styles.container}>
@@ -80,7 +130,7 @@ export default function diary() {
<Text style={styles.titleText}>Nhật chuyến đi</Text> <Text style={styles.titleText}>Nhật chuyến đi</Text>
{/* Search Bar */} {/* Search Bar */}
<SearchBar onSearch={handleSearch} style={{marginBottom: 10}}/> <SearchBar onSearch={handleSearch} style={{ marginBottom: 10 }} />
{/* Filter Button */} {/* Filter Button */}
<FilterButton onPress={handleFilter} /> <FilterButton onPress={handleFilter} />
@@ -111,6 +161,11 @@ export default function diary() {
key={trip.id} key={trip.id}
trip={trip} trip={trip}
onPress={() => handleTripPress(trip.id)} onPress={() => handleTripPress(trip.id)}
onView={() => handleViewTrip(trip.id)}
onEdit={() => handleEditTrip(trip.id)}
onTeam={() => handleViewTeam(trip.id)}
onSend={() => handleSendTrip(trip.id)}
onDelete={() => handleDeleteTrip(trip.id)}
/> />
))} ))}
@@ -141,13 +196,13 @@ const styles = StyleSheet.create({
}, },
container: { container: {
flex: 1, flex: 1,
padding: 16, padding: 10,
}, },
titleText: { titleText: {
fontSize: 28, fontSize: 28,
fontWeight: "700", fontWeight: "700",
lineHeight: 36, lineHeight: 36,
marginBottom: 20, marginBottom: 10,
color: "#111827", color: "#111827",
fontFamily: Platform.select({ fontFamily: Platform.select({
ios: "System", ios: "System",

View File

@@ -1,18 +1,33 @@
import DraggablePanel from "@/components/DraggablePanel";
import IconButton from "@/components/IconButton";
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel"; import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel"; import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
import { IOS_PLATFORM, LIGHT_THEME } from "@/constants"; import ShipInfo from "@/components/map/ShipInfo";
import { TagState, TagStateCallbackPayload } from "@/components/map/TagState";
import ShipSearchForm, {
SearchShipResponse,
} from "@/components/ShipSearchForm";
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import { useRef, useState } from "react"; import { searchThingEventBus } from "@/services/device_events";
import { Animated, StyleSheet, View } from "react-native"; import { getShipIcon } from "@/services/map_service";
import MapView from "react-native-maps"; import eventBus from "@/utils/eventBus";
import { AntDesign } from "@expo/vector-icons";
import { useEffect, useRef, useState } from "react";
import {
Animated,
Image,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import MapView, { Marker } from "react-native-maps";
export default function HomeScreen() { export default function HomeScreen() {
const [gpsData, setGpsData] = useState<Model.GPSResponse | null>(null);
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null); const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
const [entityData, setEntityData] = useState<
Model.TransformedEntity[] | null
>(null);
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null); const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
const [trackPointsData, setTrackPointsData] = useState< const [trackPointsData, setTrackPointsData] = useState<
Model.ShipTrackPoint[] | null Model.ShipTrackPoint[] | null
@@ -26,74 +41,83 @@ export default function HomeScreen() {
const [polygonCoordinates, setPolygonCoordinates] = useState< const [polygonCoordinates, setPolygonCoordinates] = useState<
PolygonWithLabelProps[] PolygonWithLabelProps[]
>([]); >([]);
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false);
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
const platform = usePlatform(); const platform = usePlatform();
const theme = useThemeContext().colorScheme; const theme = useThemeContext().colorScheme;
const scale = useRef(new Animated.Value(0)).current; const scale = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current; const opacity = useRef(new Animated.Value(1)).current;
const [shipSearchFormData, setShipSearchFormData] = useState<
SearchShipResponse | undefined
>(undefined);
const [tagStatePayload, setTagStatePayload] =
useState<TagStateCallbackPayload | null>(null);
// useEffect(() => { // Thêm state để quản lý tracksViewChanges
// getGpsEventBus(); const [tracksViewChanges, setTracksViewChanges] = useState(true);
// getAlarmEventBus();
// getEntitiesEventBus(); useEffect(() => {
// getBanzonesEventBus(); if (tagStatePayload) {
// getTrackPointsEventBus(); searchThings();
// const queryGpsData = (gpsData: Model.GPSResponse) => { }
// if (gpsData) { }, [tagStatePayload]);
// // console.log("GPS Data: ", gpsData);
// setGpsData(gpsData); useEffect(() => {
// } else { if (shipSearchFormData) {
// setGpsData(null); searchThings();
// setPolygonCoordinates([]); }
// setPolylineCoordinates([]); }, [shipSearchFormData]);
const bodySearchThings: Model.SearchThingBody = {
offset: 0,
limit: 50,
order: "name",
dir: "asc",
metadata: {
not_empty: "ship_id",
},
};
useEffect(() => {
searchThingEventBus(bodySearchThings);
const querySearchThingsData = (thingsData: Model.ThingsResponse) => {
const sortedThings: Model.Thing[] = (thingsData.things ?? []).sort(
(a, b) => {
const stateLevelA = a.metadata?.state_level || 0;
const stateLevelB = b.metadata?.state_level || 0;
return stateLevelB - stateLevelA; // Giảm dần
}
);
const sortedThingsResponse: Model.ThingsResponse = {
...thingsData,
things: sortedThings,
};
console.log("Things Updated: ", sortedThingsResponse.things?.length);
setThings(sortedThingsResponse);
};
eventBus.on(EVENT_SEARCH_THINGS, querySearchThingsData);
return () => {
eventBus.off(EVENT_SEARCH_THINGS, querySearchThingsData);
};
}, []);
useEffect(() => {
if (things) {
// console.log("Things Updated: ", things.things?.length);
// const gpsDatas: Model.GPSResponse[] = [];
// for (const thing of things.things || []) {
// if (thing.metadata?.gps) {
// const gps: Model.GPSResponse = JSON.parse(thing.metadata.gps);
// gpsDatas.push(gps);
// } // }
// };
// const queryAlarmData = (alarmData: Model.AlarmResponse) => {
// // console.log("Alarm Data: ", alarmData.alarms.length);
// setAlarmData(alarmData);
// };
// const queryEntityData = (entityData: Model.TransformedEntity[]) => {
// // console.log("Entities Length Data: ", entityData.length);
// setEntityData(entityData);
// };
// const queryBanzonesData = (banzoneData: Model.Zone[]) => {
// // console.log("Banzone Data: ", banzoneData.length);
// setBanzoneData(banzoneData);
// };
// const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
// // console.log("TrackPoints Data: ", TrackPointsData.length);
// if (TrackPointsData && TrackPointsData.length > 0) {
// setTrackPointsData(TrackPointsData);
// } else {
// setTrackPointsData([]);
// } // }
// }; // console.log("GPS Lenght: ", gpsDatas.length);
// setGpsData(gpsDatas);
// eventBus.on(EVENT_GPS_DATA, queryGpsData); }
// // console.log("Registering event handlers in HomeScreen"); }, [things]);
// eventBus.on(EVENT_GPS_DATA, queryGpsData);
// // console.log("Subscribed to EVENT_GPS_DATA");
// eventBus.on(EVENT_ALARM_DATA, queryAlarmData);
// // console.log("Subscribed to EVENT_ALARM_DATA");
// eventBus.on(EVENT_ENTITY_DATA, queryEntityData);
// // console.log("Subscribed to EVENT_ENTITY_DATA");
// eventBus.on(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
// // console.log("Subscribed to EVENT_TRACK_POINTS_DATA");
// eventBus.once(EVENT_BANZONE_DATA, queryBanzonesData);
// // console.log("Subscribed once to EVENT_BANZONE_DATA");
// return () => {
// // console.log("Unregistering event handlers in HomeScreen");
// eventBus.off(EVENT_GPS_DATA, queryGpsData);
// // console.log("Unsubscribed EVENT_GPS_DATA");
// eventBus.off(EVENT_ALARM_DATA, queryAlarmData);
// // console.log("Unsubscribed EVENT_ALARM_DATA");
// eventBus.off(EVENT_ENTITY_DATA, queryEntityData);
// // console.log("Unsubscribed EVENT_ENTITY_DATA");
// eventBus.off(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
// // console.log("Unsubscribed EVENT_TRACK_POINTS_DATA");
// };
// }, []);
// useEffect(() => { // useEffect(() => {
// setPolylineCoordinates([]); // setPolylineCoordinates([]);
@@ -176,6 +200,7 @@ export default function HomeScreen() {
// }, [banzoneData, entityData]); // }, [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) => {
const baseZoom = 10; const baseZoom = 10;
const baseRadius = 100; const baseRadius = 100;
@@ -203,7 +228,7 @@ export default function HomeScreen() {
// Sau lần đầu, return undefined để không force region // Sau lần đầu, return undefined để không force region
return undefined; return undefined;
} }
if (!gpsData) { if (things?.things?.length === 0) {
return { return {
latitude: 15.70581, latitude: 15.70581,
longitude: 116.152685, longitude: 116.152685,
@@ -213,8 +238,12 @@ export default function HomeScreen() {
} }
return { return {
latitude: gpsData.lat, latitude: things?.things?.[0]?.metadata?.gps
longitude: gpsData.lon, ? JSON.parse(things.things[0].metadata.gps).lat
: 15.70581,
longitude: things?.things?.[0]?.metadata?.gps
? JSON.parse(things.things[0].metadata.gps).lon
: 116.152685,
latitudeDelta: 0.05, latitudeDelta: 0.05,
longitudeDelta: 0.05, longitudeDelta: 0.05,
}; };
@@ -226,51 +255,172 @@ export default function HomeScreen() {
}, 2000); }, 2000);
}; };
// useEffect(() => { useEffect(() => {
// if (alarmData?.level === 3) { for (var thing of things?.things || []) {
// const loop = Animated.loop( if (thing.metadata?.state_level === 3) {
// Animated.sequence([ const loop = Animated.loop(
// Animated.parallel([ Animated.sequence([
// Animated.timing(scale, { Animated.parallel([
// toValue: 3, // nở to 3 lần Animated.timing(scale, {
// duration: 1500, toValue: 3, // nở to 3 lần
// useNativeDriver: true, duration: 1500,
// }), useNativeDriver: true,
// Animated.timing(opacity, { }),
// toValue: 0, // mờ dần Animated.timing(opacity, {
// duration: 1500, toValue: 0, // mờ dần
// useNativeDriver: true, duration: 1500,
// }), useNativeDriver: true,
// ]), }),
// Animated.parallel([ ]),
// Animated.timing(scale, { Animated.parallel([
// toValue: 0, Animated.timing(scale, {
// duration: 0, toValue: 0,
// useNativeDriver: true, duration: 0,
// }), useNativeDriver: true,
// Animated.timing(opacity, { }),
// toValue: 1, Animated.timing(opacity, {
// duration: 0, toValue: 1,
// useNativeDriver: true, duration: 0,
// }), useNativeDriver: true,
// ]), }),
// ]) ]),
// ); ])
// loop.start(); );
// return () => loop.stop(); loop.start();
// } return () => loop.stop();
// }, [alarmData?.level, scale, opacity]); }
}
}, [things, scale, opacity]);
// Tắt tracksViewChanges sau khi map đã load xong
useEffect(() => {
if (!isFirstLoad) {
// Delay một chút để đảm bảo markers đã render xong
const timer = setTimeout(() => {
setTracksViewChanges(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [isFirstLoad]);
const searchThings = async () => {
console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
// Xây dựng query state dựa trên logic bạn cung cấp
const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : "";
const stateSosQuery = tagStatePayload?.isSos ? "sos" : "";
const stateWarningQuery = tagStatePayload?.isWarning
? stateNormalQuery + ",warning"
: stateNormalQuery;
const stateCriticalQuery = tagStatePayload?.isDangerous
? stateWarningQuery + ",critical"
: stateWarningQuery;
// Nếu bật tất cả filter thì không cần truyền stateQuery
const stateQuery =
tagStatePayload?.isNormal &&
tagStatePayload?.isWarning &&
tagStatePayload?.isDangerous &&
tagStatePayload?.isSos
? ""
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(",");
let metaFormQuery = {};
if (tagStatePayload?.isDisconected)
metaFormQuery = { ...metaFormQuery, connected: false };
if (shipSearchFormData?.ship_name) {
metaFormQuery = {
...metaFormQuery,
ship_name: shipSearchFormData?.ship_name,
};
}
if (shipSearchFormData?.reg_number) {
metaFormQuery = {
...metaFormQuery,
reg_number: shipSearchFormData?.reg_number,
};
}
if (
shipSearchFormData?.ship_length[0] !== 0 ||
shipSearchFormData?.ship_length[1] !== 100
) {
metaFormQuery = {
...metaFormQuery,
ship_length: shipSearchFormData?.ship_length,
};
}
if (
shipSearchFormData?.ship_power[0] !== 0 ||
shipSearchFormData?.ship_power[1] !== 100000
) {
metaFormQuery = {
...metaFormQuery,
ship_power: shipSearchFormData?.ship_power,
};
}
if (shipSearchFormData?.alarm_list) {
metaFormQuery = {
...metaFormQuery,
alarm_list: shipSearchFormData?.alarm_list,
};
}
if (shipSearchFormData?.ship_type) {
metaFormQuery = {
...metaFormQuery,
ship_type: shipSearchFormData?.ship_type,
};
}
if (shipSearchFormData?.ship_group_id) {
metaFormQuery = {
...metaFormQuery,
ship_group_id: shipSearchFormData?.ship_group_id,
};
}
// Tạo metadata query
const metaStateQuery =
stateQuery !== "" ? { state_level: stateQuery } : null;
// Tạo body search với filter
const searchParams: Model.SearchThingBody = {
offset: 0,
limit: 50,
order: "name",
dir: "asc",
metadata: {
...metaFormQuery,
...metaStateQuery,
not_empty: "ship_id",
},
};
console.log("Search Params: ", searchParams);
// Gọi API tìm kiếm
searchThingEventBus(searchParams);
};
const handleOnSubmitSearchForm = async (data: SearchShipResponse) => {
setShipSearchFormData(data);
setShipSearchFormOpen(false);
};
const hasActiveFilters = shipSearchFormData
? shipSearchFormData.ship_name !== "" ||
shipSearchFormData.reg_number !== "" ||
shipSearchFormData.ship_length[0] !== 0 ||
shipSearchFormData.ship_length[1] !== 100 ||
shipSearchFormData.ship_power[0] !== 0 ||
shipSearchFormData.ship_power[1] !== 100000 ||
shipSearchFormData.ship_type !== "" ||
shipSearchFormData.alarm_list !== "" ||
shipSearchFormData.ship_group_id !== "" ||
shipSearchFormData.group_id !== ""
: false;
return ( return (
<View <GestureHandlerRootView style={styles.container}>
// edges={["top"]} <View style={styles.container}>
style={styles.container}
>
<MapView <MapView
onMapReady={handleMapReady} onMapReady={handleMapReady}
onRegionChangeComplete={handleRegionChangeComplete} onRegionChangeComplete={handleRegionChangeComplete}
style={styles.map} style={styles.map}
// initialRegion={getMapRegion()}
region={getMapRegion()} region={getMapRegion()}
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"} userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
showsBuildings={false} showsBuildings={false}
@@ -279,87 +429,41 @@ export default function HomeScreen() {
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"} mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
rotateEnabled={false} rotateEnabled={false}
> >
{/* {trackPointsData && {things?.things && things.things.length > 0 && (
trackPointsData.length > 0 &&
trackPointsData.map((point, index) => {
// console.log(`Rendering circle ${index}:`, point);
return (
<Circle
key={`circle-${index}`}
center={{
latitude: point.lat,
longitude: point.lon,
}}
// zIndex={50}
// radius={platform === IOS_PLATFORM ? 200 : 50}
radius={circleRadius}
strokeColor="rgba(16, 85, 201, 0.7)"
fillColor="rgba(16, 85, 201, 0.7)"
strokeWidth={2}
/>
);
})}
{polylineCoordinates.length > 0 && (
<> <>
{polylineCoordinates.map((polyline, index) => ( {things.things
<PolylineWithLabel .filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
key={`polyline-${index}-${gpsData?.lat || 0}-${ .map((thing, index) => {
gpsData?.lon || 0 const gpsData: Model.GPSResponse = JSON.parse(
}`} thing.metadata!.gps!
coordinates={polyline.coordinates}
label={polyline.label}
content={polyline.content}
strokeColor="#FF5733"
strokeWidth={4}
showDistance={false}
// zIndex={50}
/>
))}
</>
)}
{polygonCoordinates.length > 0 && (
<>
{polygonCoordinates.map((polygon, index) => {
return (
<PolygonWithLabel
key={`polygon-${index}-${gpsData?.lat || 0}-${
gpsData?.lon || 0
}`}
coordinates={polygon.coordinates}
label={polygon.label}
content={polygon.content}
fillColor="rgba(16, 85, 201, 0.6)"
strokeColor="rgba(16, 85, 201, 0.8)"
strokeWidth={2}
// zIndex={50}
zoomLevel={zoomLevel}
/>
); );
})}
</> // Tạo unique key dựa trên thing.id hoặc tọa độ
)} */} const uniqueKey = thing.id
{/* {gpsData !== null && ( ? `marker-${thing.id}-${index}`
: `marker-${gpsData.lat.toFixed(6)}-${gpsData.lon.toFixed(
6
)}-${index}`;
return (
<Marker <Marker
key={ key={uniqueKey}
platform === IOS_PLATFORM
? `${gpsData.lat}-${gpsData.lon}`
: "gps-data"
}
coordinate={{ coordinate={{
latitude: gpsData.lat, latitude: gpsData.lat,
longitude: gpsData.lon, longitude: gpsData.lon,
}} }}
zIndex={20} zIndex={50}
anchor={ anchor={{ x: 0.5, y: 0.5 }}
platform === IOS_PLATFORM // Chỉ tracks changes khi cần thiết
? { x: 0.5, y: 0.5 } tracksViewChanges={
: { x: 0.6, y: 0.4 } platform === IOS_PLATFORM ? tracksViewChanges : true
} }
tracksViewChanges={platform === IOS_PLATFORM ? true : undefined} // Thêm identifier để iOS optimize
identifier={uniqueKey}
> >
<View className="w-8 h-8 items-center justify-center"> <View className="w-8 h-8 items-center justify-center">
<View style={styles.pingContainer}> <View style={styles.pingContainer}>
{alarmData?.level === 3 && ( {thing.metadata?.state_level === 3 && (
<Animated.View <Animated.View
style={[ style={[
styles.pingCircle, styles.pingCircle,
@@ -370,14 +474,16 @@ export default function HomeScreen() {
]} ]}
/> />
)} )}
<RNImage <Image
source={(() => { source={(() => {
const icon = getShipIcon( const icon = getShipIcon(
alarmData?.level || 0, thing.metadata?.state_level || 0,
gpsData.fishing gpsData.fishing
); );
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing); // console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
return typeof icon === "string" ? { uri: icon } : icon; return typeof icon === "string"
? { uri: icon }
: icon;
})()} })()}
style={{ style={{
width: 32, width: 32,
@@ -385,7 +491,8 @@ export default function HomeScreen() {
transform: [ transform: [
{ {
rotate: `${ rotate: `${
typeof gpsData.h === "number" && !isNaN(gpsData.h) typeof gpsData.h === "number" &&
!isNaN(gpsData.h)
? gpsData.h ? gpsData.h
: 0 : 0
}deg`, }deg`,
@@ -396,14 +503,129 @@ export default function HomeScreen() {
</View> </View>
</View> </View>
</Marker> </Marker>
)} */} );
})}
</>
)}
</MapView> </MapView>
<View className="absolute top-20 left-5">
{!isPanelExpanded && (
<IconButton
icon={<AntDesign name="filter" size={16} />}
type="primary"
size="large"
style={{
borderRadius: 10,
backgroundColor: hasActiveFilters ? "#B8D576" : "#fff",
}}
onPress={() => setShipSearchFormOpen(true)}
></IconButton>
)}
</View>
{/* <View className="absolute top-14 right-2 shadow-md"> {/* <View className="absolute top-14 right-2 shadow-md">
<SosButton /> <SosButton />
</View> </View>
<GPSInfoPanel gpsData={gpsData!} /> */} <GPSInfoPanel gpsData={gpsData!} /> */}
{/* Draggable Panel */}
<DraggablePanel
minHeightPct={0.1}
maxHeightPct={0.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>
<View style={{ width: "100%", paddingVertical: 8, flex: 1 }}>
<View className="flex flex-row items-center">
<View className="flex-1 justify-center">
<Text
style={{
fontSize: 20,
textAlign: "center",
fontWeight: "600",
}}
>
Danh sách tàu thuyền
</Text>
</View>
{isPanelExpanded && (
<IconButton
icon={<AntDesign name="filter" size={16} />}
type="primary"
shape="circle"
size="middle"
style={{
// borderRadius: 10,
backgroundColor: hasActiveFilters ? "#B8D576" : "#fff",
}}
onPress={() => setShipSearchFormOpen(true)}
></IconButton>
)}
</View>
<ScrollView
style={{ width: "100%", marginTop: 8, flex: 1 }}
contentContainerStyle={{
alignItems: "center",
paddingBottom: 24,
}}
showsVerticalScrollIndicator={false}
>
{things && things.things && things.things.length > 0 && (
<>
{things.things.map((thing, index) => {
return (
<View
key={thing.id ?? index}
style={{ width: "100%", alignItems: "center" }}
>
<ShipInfo thingMetadata={thing.metadata} />
{index < (things.things?.length ?? 0) - 1 && (
<View
style={{
width: "50%",
height: 1,
backgroundColor: "#E5E7EB",
marginVertical: 8,
borderRadius: 1,
}}
/>
)}
</View>
);
})}
</>
)}
</ScrollView>
</View>
</>
</DraggablePanel>
<ShipSearchForm
initialValues={shipSearchFormData}
isOpen={shipSearchFormOpen}
onClose={() => setShipSearchFormOpen(false)}
onSubmit={handleOnSubmitSearchForm}
/>
</View>
</GestureHandlerRootView>
); );
} }

View File

@@ -1,13 +1,20 @@
import ShipSearchForm from "@/components/ShipSearchForm";
import { useState } from "react";
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
export default function warning() { export default function warning() {
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(true);
return ( return (
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1 }}>
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.titleText}>Cảnh báo</Text> <Text style={styles.titleText}>Cảnh báo</Text>
</View> </View>
<ShipSearchForm
isOpen={shipSearchFormOpen}
onClose={() => setShipSearchFormOpen(false)}
/>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -14,7 +14,10 @@ import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth"; import { setRouterInstance } from "@/config/auth";
import "@/global.css"; import "@/global.css";
import { I18nProvider } from "@/hooks/use-i18n"; import { I18nProvider } from "@/hooks/use-i18n";
import { ThemeProvider as AppThemeProvider, useThemeContext } from "@/hooks/use-theme-context"; import {
ThemeProvider as AppThemeProvider,
useThemeContext,
} from "@/hooks/use-theme-context";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import "../global.css"; import "../global.css";
function AppContent() { function AppContent() {

View File

@@ -0,0 +1,246 @@
import { Ionicons } from "@expo/vector-icons";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import React, { useEffect, useState } from "react";
import {
Pressable,
StyleSheet,
Text,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
interface DraggablePanelProps {
minHeightPct?: number;
maxHeightPct?: number;
initialState?: "min" | "max";
onExpandedChange?: (expanded: boolean) => void;
children?: React.ReactNode;
}
export default function DraggablePanel({
minHeightPct = 0.1,
maxHeightPct = 0.6,
initialState = "min",
onExpandedChange,
children,
}: DraggablePanelProps) {
const { height: screenHeight } = useWindowDimensions();
const bottomOffset = useBottomTabBarHeight();
const minHeight = screenHeight * minHeightPct;
const maxHeight = screenHeight * maxHeightPct;
const [iconName, setIconName] = useState<"chevron-down" | "chevron-up">(
initialState === "max" ? "chevron-down" : "chevron-up"
);
// Sử dụng translateY để điều khiển vị trí
const translateY = useSharedValue(
initialState === "min"
? screenHeight - minHeight - bottomOffset
: screenHeight - maxHeight - bottomOffset
);
const isExpanded = useSharedValue(initialState === "max");
// Update khi screen height thay đổi (xoay màn hình)
useEffect(() => {
const currentHeight = isExpanded.value ? maxHeight : minHeight;
translateY.value = screenHeight - currentHeight - bottomOffset;
}, [screenHeight, minHeight, maxHeight, bottomOffset]);
const notifyExpandedChange = (expanded: boolean) => {
if (onExpandedChange) {
onExpandedChange(expanded);
}
};
const togglePanel = () => {
const newExpanded = !isExpanded.value;
isExpanded.value = newExpanded;
const targetY = newExpanded
? screenHeight - maxHeight - bottomOffset
: screenHeight - minHeight - bottomOffset;
translateY.value = withTiming(targetY, {
duration: 500,
});
notifyExpandedChange(newExpanded);
};
const startY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onStart(() => {
startY.value = translateY.value;
})
.onUpdate((event) => {
// Cập nhật translateY theo gesture
const newY = startY.value + event.translationY;
// Clamp giá trị trong khoảng [screenHeight - maxHeight - bottomOffset, screenHeight - minHeight - bottomOffset]
const minY = screenHeight - maxHeight - bottomOffset;
const maxY = screenHeight - minHeight - bottomOffset;
if (newY >= minY && newY <= maxY) {
translateY.value = newY;
} else if (newY < minY) {
translateY.value = minY;
} else if (newY > maxY) {
translateY.value = maxY;
}
})
.onEnd((event) => {
// Tính toán vị trí để snap
const currentHeight = screenHeight - translateY.value - bottomOffset;
const midHeight = (minHeight + maxHeight) / 2;
// Kiểm tra velocity để quyết định snap
const snapToMax = event.velocityY < -100 || currentHeight > midHeight;
const targetY = snapToMax
? screenHeight - maxHeight - bottomOffset + 40
: screenHeight - minHeight - bottomOffset;
isExpanded.value = snapToMax;
runOnJS(notifyExpandedChange)(snapToMax);
translateY.value = withSpring(targetY, {
damping: 20,
stiffness: 50,
});
});
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: translateY.value }],
};
});
// Sử dụng useAnimatedReaction để cập nhật icon dựa trên chiều cao
useAnimatedReaction(
() => {
const currentHeight = screenHeight - translateY.value - bottomOffset;
return currentHeight > minHeight;
},
(isCurrentlyExpanded) => {
const newIcon = isCurrentlyExpanded ? "chevron-down" : "chevron-up";
runOnJS(setIconName)(newIcon);
}
);
return (
<Animated.View style={[styles.panelContainer, animatedStyle]}>
<View style={[styles.panel, { height: maxHeight }]}>
{/* Header với drag handle và nút toggle */}
<GestureDetector gesture={panGesture}>
<Pressable onPress={togglePanel} style={styles.header}>
<View style={styles.dragHandle} />
<TouchableOpacity
onPress={togglePanel}
style={styles.toggleButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name={iconName} size={24} color="#666" />
</TouchableOpacity>
</Pressable>
</GestureDetector>
{/* Nội dung */}
<View style={styles.content}>
{children || (
<View style={styles.placeholderContent}>
<Text style={styles.placeholderText}>Draggable Panel</Text>
<Text style={styles.placeholderSubtext}>
Click hoặc kéo đ mở rộng panel này
</Text>
<Text style={styles.placeholderSubtext}>
Min: {(minHeightPct * 100).toFixed(0)}% | Max:{" "}
{(maxHeightPct * 100).toFixed(0)}%
</Text>
</View>
)}
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
panelContainer: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
pointerEvents: "box-none",
},
panel: {
position: "absolute",
top: 0,
left: 0,
right: 0,
backgroundColor: "white",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -2,
},
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 10,
},
header: {
paddingTop: 8,
paddingBottom: 8,
paddingHorizontal: 16,
alignItems: "center",
justifyContent: "center",
},
dragHandle: {
width: 40,
height: 4,
backgroundColor: "#D1D5DB",
borderRadius: 2,
marginBottom: 8,
},
toggleButton: {
position: "absolute",
right: 16,
top: 1,
padding: 4,
},
content: {
flex: 1,
paddingHorizontal: 16,
// paddingBottom: 16,
},
placeholderContent: {
alignItems: "center",
paddingTop: 20,
},
placeholderText: {
fontSize: 18,
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
placeholderSubtext: {
fontSize: 14,
color: "#666",
textAlign: "center",
marginTop: 4,
},
});

View File

@@ -20,16 +20,16 @@ export interface SelectOption {
} }
export interface SelectProps { export interface SelectProps {
value?: string | number; value?: string | number | (string | number)[];
defaultValue?: string | number; defaultValue?: string | number | (string | number)[];
options: SelectOption[]; options: SelectOption[];
onChange?: (value: string | number | undefined) => void; onChange?: (value: string | number | (string | number)[] | undefined) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
allowClear?: boolean; allowClear?: boolean;
showSearch?: boolean; showSearch?: boolean;
mode?: "single" | "multiple"; // multiple not implemented yet mode?: "single" | "multiple";
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
size?: "small" | "middle" | "large"; size?: "small" | "middle" | "large";
listStyle?: StyleProp<ViewStyle>; listStyle?: StyleProp<ViewStyle>;
@@ -38,7 +38,7 @@ export interface SelectProps {
/** /**
* Select * Select
* A Select component inspired by Ant Design, adapted for React Native. * A Select component inspired by Ant Design, adapted for React Native.
* Supports single selection, search, clear, loading, disabled states. * Supports single and multiple selection, search, clear, loading, disabled states.
*/ */
const Select: React.FC<SelectProps> = ({ const Select: React.FC<SelectProps> = ({
value, value,
@@ -55,16 +55,23 @@ const Select: React.FC<SelectProps> = ({
listStyle, listStyle,
size = "middle", size = "middle",
}) => { }) => {
const [selectedValue, setSelectedValue] = useState< const initialValue = value ?? defaultValue;
string | number | undefined const [selectedValues, setSelectedValues] = useState<(string | number)[]>(
>(value ?? defaultValue); Array.isArray(initialValue)
? initialValue
: initialValue
? [initialValue]
: []
);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [containerHeight, setContainerHeight] = useState(0); const [containerHeight, setContainerHeight] = useState(0);
const [textHeight, setTextHeight] = useState(0);
useEffect(() => { useEffect(() => {
setSelectedValue(value); const newVal = value ?? defaultValue;
}, [value]); setSelectedValues(Array.isArray(newVal) ? newVal : newVal ? [newVal] : []);
}, [value, defaultValue]);
const filteredOptions = showSearch const filteredOptions = showSearch
? options.filter((opt) => ? options.filter((opt) =>
@@ -72,17 +79,31 @@ const Select: React.FC<SelectProps> = ({
) )
: options; : options;
const selectedOption = options.find((opt) => opt.value === selectedValue);
const handleSelect = (val: string | number) => { const handleSelect = (val: string | number) => {
setSelectedValue(val); let newSelected: (string | number)[];
onChange?.(val); if (mode === "single") {
newSelected = [val];
} else {
newSelected = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
}
setSelectedValues(newSelected);
onChange?.(
mode === "single"
? newSelected.length > 0
? newSelected[0]
: undefined
: newSelected
);
if (mode === "single") {
setIsOpen(false); setIsOpen(false);
setSearchText(""); setSearchText("");
}
}; };
const handleClear = () => { const handleClear = () => {
setSelectedValue(undefined); setSelectedValues([]);
onChange?.(undefined); onChange?.(undefined);
}; };
@@ -100,13 +121,26 @@ const Select: React.FC<SelectProps> = ({
? colors.backgroundSecondary ? colors.backgroundSecondary
: colors.surface; : colors.surface;
let displayText = placeholder;
if (selectedValues.length > 0) {
if (mode === "single") {
const opt = options.find((o) => o.value === selectedValues[0]);
displayText = opt?.label || placeholder;
} else {
const labels = selectedValues
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean);
displayText = labels.join(", ");
}
}
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.container, styles.container,
{ {
height: sz.height, height: Math.max(sz.height, textHeight + 16), // Add padding
paddingHorizontal: sz.paddingHorizontal, paddingHorizontal: sz.paddingHorizontal,
backgroundColor: selectBackgroundColor, backgroundColor: selectBackgroundColor,
borderColor: disabled ? colors.border : colors.primary, borderColor: disabled ? colors.border : colors.primary,
@@ -129,19 +163,19 @@ const Select: React.FC<SelectProps> = ({
fontSize: sz.fontSize, fontSize: sz.fontSize,
color: disabled color: disabled
? colors.textSecondary ? colors.textSecondary
: selectedValue : selectedValues.length > 0
? colors.text ? colors.text
: colors.textSecondary, : colors.textSecondary,
}, },
]} ]}
numberOfLines={1} onLayout={(e) => setTextHeight(e.nativeEvent.layout.height)}
> >
{selectedOption?.label || placeholder} {displayText}
</Text> </Text>
)} )}
</View> </View>
<View style={styles.suffix}> <View style={styles.suffix}>
{allowClear && selectedValue && !loading ? ( {allowClear && selectedValues.length > 0 && !loading ? (
<TouchableOpacity onPress={handleClear} style={styles.icon}> <TouchableOpacity onPress={handleClear} style={styles.icon}>
<AntDesign name="close" size={16} color={colors.textSecondary} /> <AntDesign name="close" size={16} color={colors.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
@@ -193,7 +227,7 @@ const Select: React.FC<SelectProps> = ({
borderBottomColor: colors.separator, borderBottomColor: colors.separator,
}, },
item.disabled && styles.optionDisabled, item.disabled && styles.optionDisabled,
selectedValue === item.value && { selectedValues.includes(item.value) && {
backgroundColor: colors.primary + "20", // Add transparency to primary color backgroundColor: colors.primary + "20", // Add transparency to primary color
}, },
]} ]}
@@ -209,7 +243,7 @@ const Select: React.FC<SelectProps> = ({
item.disabled && { item.disabled && {
color: colors.textSecondary, color: colors.textSecondary,
}, },
selectedValue === item.value && { selectedValues.includes(item.value) && {
color: colors.primary, color: colors.primary,
fontWeight: "600", fontWeight: "600",
}, },
@@ -217,7 +251,7 @@ const Select: React.FC<SelectProps> = ({
> >
{item.label} {item.label}
</Text> </Text>
{selectedValue === item.value && ( {selectedValues.includes(item.value) && (
<AntDesign name="check" size={16} color={colors.primary} /> <AntDesign name="check" size={16} color={colors.primary} />
)} )}
</TouchableOpacity> </TouchableOpacity>

View File

@@ -0,0 +1,534 @@
import { Colors } from "@/config";
import { queryShipGroups } from "@/controller/DeviceController";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { useShipTypes } from "@/state/use-ship-types";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
Animated,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import Select from "./Select";
import Slider from "./Slider";
interface ShipSearchFormProps {
initialValues?: Partial<SearchShipResponse>;
isOpen: boolean;
onClose: () => void;
onSubmit?: (data: SearchShipResponse) => void;
}
export interface SearchShipResponse {
ship_name: string;
ship_length: [number, number];
reg_number: string;
ship_power: [number, number];
ship_type: string | number;
alarm_list: string;
ship_group_id: string;
group_id: string;
}
const ShipSearchForm = (props: ShipSearchFormProps) => {
const { colors, colorScheme } = useTheme();
const styles = useMemo(
() => createStyles(colors, colorScheme),
[colors, colorScheme]
);
const { shipTypes, getShipTypes } = useShipTypes();
const [groupShips, setGroupShips] = useState<Model.ShipGroup[]>([]);
const [slideAnim] = useState(new Animated.Value(0));
const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({
defaultValues: {
ship_name: props.initialValues?.ship_name || "",
reg_number: props.initialValues?.reg_number || "",
ship_length: [0, 100],
ship_power: [0, 100000],
ship_type: props.initialValues?.ship_type || "",
alarm_list: props.initialValues?.alarm_list || "",
ship_group_id: props.initialValues?.ship_group_id || "",
group_id: props.initialValues?.group_id || "",
},
});
const shipLengthValue = watch("ship_length");
const shipPowerValue = watch("ship_power");
useEffect(() => {
if (shipTypes.length === 0) {
getShipTypes();
}
}, [shipTypes]);
useEffect(() => {
getShipGroups();
}, []);
useEffect(() => {
if (props.isOpen) {
Animated.spring(slideAnim, {
toValue: 1,
useNativeDriver: true,
tension: 50,
friction: 8,
}).start();
} else {
slideAnim.setValue(0);
}
}, [props.isOpen]);
useEffect(() => {
if (props.initialValues) {
reset({
ship_name: props.initialValues.ship_name || "",
reg_number: props.initialValues.reg_number || "",
ship_length: [
props.initialValues.ship_length?.[0] || 0,
props.initialValues.ship_length?.[1] || 100,
],
ship_power: [
props.initialValues.ship_power?.[0] || 0,
props.initialValues.ship_power?.[1] || 100000,
],
ship_type: props.initialValues.ship_type || "",
alarm_list: props.initialValues.alarm_list || "",
ship_group_id: props.initialValues.ship_group_id || "",
group_id: props.initialValues.group_id || "",
});
}
}, [props.initialValues]);
const getShipGroups = async () => {
try {
const response = await queryShipGroups();
if (response && response.data) {
setGroupShips(response.data);
}
} catch (error) {
console.error("Error fetching ship groups:", error);
}
};
const alarmListLabel = [
{
label: "Tiếp cận vùng hạn chế",
value: "50:10",
},
{
label: "Đã ra (vào) vùng hạn chế)",
value: "50:11",
},
{
label: "Đang đánh bắt trong vùng hạn chế",
value: "50:12",
},
];
const onSubmitForm = (data: SearchShipResponse) => {
props.onSubmit?.(data);
console.log("Data: ", data);
// props.onClose();
};
const onReset = () => {
reset({
ship_name: "",
reg_number: "",
ship_length: [0, 100],
ship_power: [0, 100000],
ship_type: "",
alarm_list: "",
ship_group_id: "",
group_id: "",
});
};
const translateY = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [600, 0],
});
return (
<Modal
animationType="fade"
transparent={true}
visible={props.isOpen}
onRequestClose={props.onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Pressable style={styles.backdrop} onPress={props.onClose}>
<Animated.View
style={[styles.modalContent, { transform: [{ translateY }] }]}
onStartShouldSetResponder={() => true}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.dragIndicator} />
<Text style={[styles.headerTitle, { color: colors.text }]}>
Tìm kiếm tàu
</Text>
<TouchableOpacity
onPress={props.onClose}
style={styles.closeButton}
>
<Text style={[styles.closeButtonText, { color: colors.text }]}>
</Text>
</TouchableOpacity>
</View>
{/* Form Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.formSection}>
{/* Tên tàu */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Tên tàu
</Text>
<Controller
control={control}
name="ship_name"
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Hoàng Sa 001"
placeholderTextColor={colors.textSecondary}
style={[
styles.input,
{
borderColor: colors.border,
backgroundColor: colors.surface,
color: colors.text,
},
]}
value={value}
onChangeText={onChange}
/>
)}
/>
</View>
{/* Số đăng ký */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Số đăng
</Text>
<Controller
control={control}
name="reg_number"
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="VN-00001"
placeholderTextColor={colors.textSecondary}
style={[
styles.input,
{
borderColor: colors.border,
backgroundColor: colors.surface,
color: colors.text,
},
]}
value={value}
onChangeText={onChange}
/>
)}
/>
</View>
{/* Chiều dài */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Chiều dài ({shipLengthValue[0]}m - {shipLengthValue[1]}m)
</Text>
<Controller
control={control}
name="ship_length"
render={({ field: { onChange, value } }) => (
<View style={styles.sliderContainer}>
<Slider
range={true}
min={0}
max={100}
step={1}
value={value}
marks={{
0: "0m",
50: "50m",
100: "100m",
}}
onValueChange={(val) =>
onChange(val as [number, number])
}
activeColor={colors.primary}
/>
</View>
)}
/>
</View>
{/* Công suất */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Công suất ({shipPowerValue[0]}kW - {shipPowerValue[1]}kW)
</Text>
<Controller
control={control}
name="ship_power"
render={({ field: { onChange, value } }) => (
<View style={styles.sliderContainer}>
<Slider
range={true}
min={0}
max={100000}
step={1000}
value={value}
marks={{
0: "0kW",
50000: "50,000kW",
100000: "100,000kW",
}}
onValueChange={(val) =>
onChange(val as [number, number])
}
activeColor={colors.primary}
/>
</View>
)}
/>
</View>
{/* Loại tàu */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Loại tàu
</Text>
<Controller
control={control}
name="ship_type"
render={({ field: { onChange, value } }) => (
<Select
options={shipTypes.map((type) => ({
label: type.name || "",
value: type.id || 0,
}))}
placeholder="Chọn loại tàu"
mode="multiple"
value={value}
onChange={onChange}
/>
)}
/>
</View>
{/* Cảnh báo */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Cảnh báo
</Text>
<Controller
control={control}
name="alarm_list"
render={({ field: { onChange, value } }) => (
<Select
options={alarmListLabel.map((type) => ({
label: type.label || "",
value: type.value || "",
}))}
placeholder="Chọn loại cảnh báo"
mode="multiple"
value={value}
onChange={onChange}
/>
)}
/>
</View>
{/* Đội tàu */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Đi tàu
</Text>
<Controller
control={control}
name="ship_group_id"
render={({ field: { onChange, value } }) => (
<Select
options={groupShips.map((group) => ({
label: group.name || "",
value: group.id || "",
}))}
placeholder="Chọn đội tàu"
mode="multiple"
value={value}
onChange={onChange}
/>
)}
/>
</View>
<View className="h-12"></View>
</View>
</ScrollView>
{/* Action Buttons */}
<View
style={[styles.actionButtons, { borderTopColor: colors.border }]}
>
<TouchableOpacity
style={[styles.resetButton, { borderColor: colors.border }]}
onPress={onReset}
>
<Text style={[styles.resetButtonText, { color: colors.text }]}>
Đt lại
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
{ backgroundColor: colors.primary },
]}
onPress={handleSubmit(onSubmitForm)}
>
<Text style={styles.submitButtonText}>Tìm kiếm</Text>
</TouchableOpacity>
</View>
</Animated.View>
</Pressable>
</GestureHandlerRootView>
</Modal>
);
};
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContent: {
height: "85%",
backgroundColor: colors.background,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 10,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: colors.border,
position: "relative",
},
dragIndicator: {
position: "absolute",
top: 8,
width: 40,
height: 4,
backgroundColor: colors.border,
borderRadius: 2,
},
headerTitle: {
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
closeButton: {
position: "absolute",
right: 16,
top: 16,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
},
closeButtonText: {
fontSize: 20,
fontWeight: "300",
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 20,
},
formSection: {
paddingHorizontal: 20,
paddingTop: 20,
},
fieldGroup: {
marginBottom: 24,
},
label: {
fontSize: 15,
fontWeight: "600",
marginBottom: 10,
},
input: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
},
sliderContainer: {
paddingHorizontal: 4,
paddingTop: 8,
},
actionButtons: {
flexDirection: "row",
paddingHorizontal: 20,
paddingVertical: 16,
gap: 12,
borderTopWidth: 1,
},
resetButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
resetButtonText: {
fontSize: 16,
fontWeight: "600",
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
submitButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
});
export default ShipSearchForm;

347
components/Slider.tsx Normal file
View File

@@ -0,0 +1,347 @@
import { useEffect, useState } from "react";
import { LayoutChangeEvent, StyleSheet, Text, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
interface SliderProps {
min?: number;
max?: number;
step?: number;
range?: boolean;
value?: number | [number, number];
onValueChange?: (value: any) => void;
onSlidingComplete?: (value: any) => void;
disabled?: boolean;
trackHeight?: number;
thumbSize?: number;
activeColor?: string;
inactiveColor?: string;
marks?: Record<number, string>;
style?: any;
}
export default function Slider({
min = 0,
max = 100,
step = 1,
range = false,
value = 0,
onValueChange,
onSlidingComplete,
disabled = false,
trackHeight = 4,
thumbSize = 20,
activeColor = "#1677ff", // Ant Design blue
inactiveColor = "#e5e7eb", // Gray-200
marks,
style,
}: SliderProps) {
const [width, setWidth] = useState(0);
// Shared values for positions
const translateX1 = useSharedValue(0); // Left thumb (or 0 for single)
const translateX2 = useSharedValue(0); // Right thumb (or value for single)
const isDragging1 = useSharedValue(false);
const isDragging2 = useSharedValue(false);
const scale1 = useSharedValue(1);
const scale2 = useSharedValue(1);
const context1 = useSharedValue(0);
const context2 = useSharedValue(0);
// Calculate position from value
const getPositionFromValue = (val: number) => {
"worklet";
if (width === 0) return 0;
const clampedVal = Math.min(Math.max(val, min), max);
return ((clampedVal - min) / (max - min)) * width;
};
// Calculate value from position
const getValueFromPosition = (pos: number) => {
"worklet";
if (width === 0) return min;
const percentage = Math.min(Math.max(pos, 0), width) / width;
const rawValue = min + percentage * (max - min);
// Snap to step
const steppedValue = Math.round(rawValue / step) * step;
return Math.min(Math.max(steppedValue, min), max);
};
// Update positions when props change
useEffect(() => {
if (width === 0) return;
if (range) {
const vals = Array.isArray(value) ? value : [min, value as number];
const v1 = Math.min(vals[0], vals[1]);
const v2 = Math.max(vals[0], vals[1]);
if (!isDragging1.value) {
translateX1.value = withTiming(getPositionFromValue(v1), {
duration: 200,
});
}
if (!isDragging2.value) {
translateX2.value = withTiming(getPositionFromValue(v2), {
duration: 200,
});
}
} else {
const val = typeof value === "number" ? value : value[0];
if (!isDragging2.value) {
translateX2.value = withTiming(getPositionFromValue(val), {
duration: 200,
});
}
translateX1.value = 0; // Always 0 for single slider track start
}
}, [value, width, min, max, range]);
// Thumb 1 Gesture (Only for range)
const thumb1Gesture = Gesture.Pan()
.enabled(!disabled && range)
.onStart(() => {
context1.value = translateX1.value;
isDragging1.value = true;
scale1.value = withSpring(1.2);
})
.onUpdate((event) => {
if (width === 0) return;
let newPos = context1.value + event.translationX;
// Constrain: 0 <= newPos <= translateX2
const maxPos = translateX2.value;
newPos = Math.min(Math.max(newPos, 0), maxPos);
translateX1.value = newPos;
if (onValueChange) {
const v1 = getValueFromPosition(newPos);
const v2 = getValueFromPosition(translateX2.value);
runOnJS(onValueChange)([v1, v2]);
}
})
.onEnd(() => {
isDragging1.value = false;
scale1.value = withSpring(1);
if (onSlidingComplete) {
const v1 = getValueFromPosition(translateX1.value);
const v2 = getValueFromPosition(translateX2.value);
runOnJS(onSlidingComplete)([v1, v2]);
}
});
// Thumb 2 Gesture (Main thumb for single, Right thumb for range)
const thumb2Gesture = Gesture.Pan()
.enabled(!disabled)
.onStart(() => {
context2.value = translateX2.value;
isDragging2.value = true;
scale2.value = withSpring(1.2);
})
.onUpdate((event) => {
if (width === 0) return;
let newPos = context2.value + event.translationX;
// Constrain: translateX1 <= newPos <= width
const minPos = range ? translateX1.value : 0;
newPos = Math.min(Math.max(newPos, minPos), width);
translateX2.value = newPos;
if (onValueChange) {
const v2 = getValueFromPosition(newPos);
if (range) {
const v1 = getValueFromPosition(translateX1.value);
runOnJS(onValueChange)([v1, v2]);
} else {
runOnJS(onValueChange)(v2);
}
}
})
.onEnd(() => {
isDragging2.value = false;
scale2.value = withSpring(1);
if (onSlidingComplete) {
const v2 = getValueFromPosition(translateX2.value);
if (range) {
const v1 = getValueFromPosition(translateX1.value);
runOnJS(onSlidingComplete)([v1, v2]);
} else {
runOnJS(onSlidingComplete)(v2);
}
}
});
const trackStyle = useAnimatedStyle(() => {
return {
left: range ? translateX1.value : 0,
width: range ? translateX2.value - translateX1.value : translateX2.value,
};
});
const thumb1Style = useAnimatedStyle(() => {
return {
transform: [
{ translateX: translateX1.value - thumbSize / 2 },
{ scale: scale1.value },
],
zIndex: isDragging1.value ? 10 : 1,
};
});
const thumb2Style = useAnimatedStyle(() => {
return {
transform: [
{ translateX: translateX2.value - thumbSize / 2 },
{ scale: scale2.value },
],
zIndex: isDragging2.value ? 10 : 1,
};
});
const onLayout = (event: LayoutChangeEvent) => {
setWidth(event.nativeEvent.layout.width);
};
const containerHeight = Math.max(trackHeight, thumbSize) + (marks ? 30 : 0);
const marksTop = Math.max(trackHeight, thumbSize) + 10;
return (
<View style={[styles.container, style, { height: containerHeight }]}>
<View
style={[
styles.track,
{
height: trackHeight,
backgroundColor: inactiveColor,
borderRadius: trackHeight / 2,
},
]}
onLayout={onLayout}
>
<Animated.View
style={[
styles.activeTrack,
trackStyle,
{
height: trackHeight,
backgroundColor: activeColor,
borderRadius: trackHeight / 2,
},
]}
/>
</View>
{/* Thumb 1 - Only render if range is true */}
{range && (
<GestureDetector gesture={thumb1Gesture}>
<Animated.View
style={[
styles.thumb,
thumb1Style,
{
width: thumbSize,
height: thumbSize,
borderRadius: thumbSize / 2,
backgroundColor: "white",
borderColor: activeColor,
borderWidth: 2,
},
disabled && styles.disabledThumb,
]}
/>
</GestureDetector>
)}
{/* Thumb 2 - Always render */}
<GestureDetector gesture={thumb2Gesture}>
<Animated.View
style={[
styles.thumb,
thumb2Style,
{
width: thumbSize,
height: thumbSize,
borderRadius: thumbSize / 2,
backgroundColor: "white",
borderColor: activeColor,
borderWidth: 2,
},
disabled && styles.disabledThumb,
]}
/>
</GestureDetector>
{/* Marks */}
{marks && (
<View
style={{
position: "absolute",
top: marksTop,
width: "100%",
height: 20,
}}
>
{Object.entries(marks).map(([key, label]) => {
const val = Number(key);
const pos = getPositionFromValue(val);
const leftPos = Math.max(0, Math.min(pos - 10, width - 40));
return (
<Text
key={key}
style={{
position: "absolute",
left: leftPos,
fontSize: 12,
color: "#666",
textAlign: "center",
width: "auto",
marginTop: 5,
}}
>
{label}
</Text>
);
})}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
justifyContent: "center",
width: "100%",
},
track: {
width: "100%",
position: "absolute",
overflow: "hidden",
},
activeTrack: {
position: "absolute",
left: 0,
top: 0,
},
thumb: {
position: "absolute",
left: 0,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
disabledThumb: {
borderColor: "#ccc",
},
});

View File

@@ -18,9 +18,11 @@ interface StatusDropdownProps {
const STATUS_OPTIONS: Array<{ value: TripStatus | null; label: string }> = [ const STATUS_OPTIONS: Array<{ value: TripStatus | null; label: string }> = [
{ value: null, label: "Vui lòng chọn" }, { value: null, label: "Vui lòng chọn" },
{ value: "completed", label: "Hoàn thành" }, { value: "created", label: "Đã khởi tạo" },
{ value: "pending", label: "Chờ duyệt" },
{ value: "approved", label: "Đã duyệt" },
{ value: "in-progress", label: "Đang hoạt động" }, { value: "in-progress", label: "Đang hoạt động" },
{ value: "quality-check", label: "Đã khởi tạo" }, { value: "completed", label: "Hoàn thành" },
{ value: "cancelled", label: "Đã hủy" }, { value: "cancelled", label: "Đã hủy" },
]; ];
@@ -162,7 +164,6 @@ const styles = StyleSheet.create({
paddingVertical: 16, paddingVertical: 16,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#F3F4F6", borderBottomColor: "#F3F4F6",
}, },
selectedOption: { selectedOption: {
backgroundColor: "#EFF6FF", backgroundColor: "#EFF6FF",

View File

@@ -12,13 +12,24 @@ import { Trip, TRIP_STATUS_CONFIG } from "./types";
interface TripCardProps { interface TripCardProps {
trip: Trip; trip: Trip;
onPress?: () => void; onPress?: () => void;
onView?: () => void;
onEdit?: () => void;
onTeam?: () => void;
onSend?: () => void;
onDelete?: () => void;
} }
export default function TripCard({ trip, onPress }: TripCardProps) { export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend, onDelete }: TripCardProps) {
const statusConfig = TRIP_STATUS_CONFIG[trip.status]; const statusConfig = TRIP_STATUS_CONFIG[trip.status];
// Determine which actions to show based on status
const showEdit = trip.status === 'created' || trip.status === 'pending';
const showSend = trip.status === 'created';
const showDelete = trip.status === 'pending';
return ( return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.7}> <View style={styles.card}>
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
@@ -78,6 +89,42 @@ export default function TripCard({ trip, onPress }: TripCardProps) {
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{/* Action Buttons */}
<View style={styles.divider} />
<View style={styles.actionsContainer}>
<TouchableOpacity style={styles.actionButton} onPress={onView} activeOpacity={0.7}>
<Ionicons name="eye-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>View</Text>
</TouchableOpacity>
{showEdit && (
<TouchableOpacity style={styles.actionButton} onPress={onEdit} activeOpacity={0.7}>
<Ionicons name="create-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>Edit</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.actionButton} onPress={onTeam} activeOpacity={0.7}>
<Ionicons name="people-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>Team</Text>
</TouchableOpacity>
{showSend && (
<TouchableOpacity style={styles.actionButton} onPress={onSend} activeOpacity={0.7}>
<Ionicons name="send-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>Send</Text>
</TouchableOpacity>
)}
{showDelete && (
<TouchableOpacity style={styles.actionButton} onPress={onDelete} activeOpacity={0.7}>
<Ionicons name="trash-outline" size={20} color="#EF4444" />
<Text style={[styles.actionText, styles.deleteText]}>Delete</Text>
</TouchableOpacity>
)}
</View>
</View>
); );
} }
@@ -178,4 +225,34 @@ const styles = StyleSheet.create({
duration: { duration: {
color: "#3B82F6", color: "#3B82F6",
}, },
divider: {
height: 1,
backgroundColor: "#F3F4F6",
marginTop: 16,
},
actionsContainer: {
flexDirection: "row",
justifyContent: "space-around",
paddingTop: 12,
},
actionButton: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingVertical: 8,
paddingHorizontal: 12,
},
actionText: {
fontSize: 14,
color: "#6B7280",
fontWeight: "500",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
deleteText: {
color: "#EF4444",
},
}); });

View File

@@ -54,7 +54,7 @@ export const MOCK_TRIPS: Trip[] = [
departureDate: "2025-11-18 07:00", departureDate: "2025-11-18 07:00",
returnDate: "2025-11-23 14:00", returnDate: "2025-11-23 14:00",
duration: "5 ngày 7 giờ", duration: "5 ngày 7 giờ",
status: "quality-check", status: "created",
}, },
{ {
id: "T006", id: "T006",
@@ -67,4 +67,26 @@ export const MOCK_TRIPS: Trip[] = [
duration: "6 giờ", duration: "6 giờ",
status: "in-progress", status: "in-progress",
}, },
{
id: "T007",
title: "Khảo sát vùng biển",
code: "T007",
vessel: "Ngọc Lan",
vesselCode: "V002",
departureDate: "2025-12-01 07:00",
returnDate: null,
duration: "-",
status: "pending",
},
{
id: "T008",
title: "Đánh cá xa bờ",
code: "T008",
vessel: "Việt Thắng",
vesselCode: "V003",
departureDate: "2025-12-05 05:00",
returnDate: null,
duration: "-",
status: "approved",
},
]; ];

View File

@@ -1,8 +1,10 @@
export type TripStatus = export type TripStatus =
| "completed" | "created" // Đã khởi tạo
| "in-progress" | "pending" // Chờ duyệt
| "cancelled" | "approved" // Đã duyệt
| "quality-check"; | "in-progress" // Đang hoạt động
| "completed" // Hoàn thành
| "cancelled"; // Đã hủy
export interface Trip { export interface Trip {
id: string; id: string;
@@ -17,28 +19,40 @@ export interface Trip {
} }
export const TRIP_STATUS_CONFIG = { export const TRIP_STATUS_CONFIG = {
completed: { created: {
label: "Hoàn thành", label: "Đã khởi tạo",
bgColor: "#D1FAE5", bgColor: "#F3F4F6", // Gray background
textColor: "#065F46", textColor: "#4B5563", // Gray text
icon: "checkmark-circle", icon: "document-text",
},
pending: {
label: "Chờ duyệt",
bgColor: "#FEF3C7", // Yellow background
textColor: "#92400E", // Dark yellow text
icon: "hourglass",
},
approved: {
label: "Đã duyệt",
bgColor: "#E0E7FF", // Indigo background
textColor: "#3730A3", // Dark indigo text
icon: "checkmark-done",
}, },
"in-progress": { "in-progress": {
label: "Đang diễn ra", label: "Đang hoạt động",
bgColor: "#DBEAFE", bgColor: "#DBEAFE", // Blue background
textColor: "#1E40AF", textColor: "#1E40AF", // Dark blue text
icon: "time", icon: "sync",
},
completed: {
label: "Hoàn thành",
bgColor: "#D1FAE5", // Green background
textColor: "#065F46", // Dark green text
icon: "checkmark-circle",
}, },
cancelled: { cancelled: {
label: "Đã hủy", label: "Đã hủy",
bgColor: "#FEE2E2", bgColor: "#FEE2E2", // Red background
textColor: "#991B1B", textColor: "#991B1B", // Dark red text
icon: "close-circle", icon: "close-circle",
}, },
"quality-check": {
label: "Khảo sát địa chất",
bgColor: "#D1FAE5",
textColor: "#065F46",
icon: "checkmark-circle",
},
} as const; } as const;

View File

@@ -1,6 +1,6 @@
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { useAppTheme } from "@/hooks/use-app-theme"; import { useAppTheme } from "@/hooks/use-app-theme";
import { View } from "react-native"; import { ScrollView, View } from "react-native";
interface DescriptionProps { interface DescriptionProps {
title?: string; title?: string;
@@ -13,12 +13,14 @@ export const Description = ({
const { colors } = useAppTheme(); const { colors } = useAppTheme();
return ( return (
<View className="flex-row gap-2 "> <View className="flex-row gap-2 ">
<ThemedText <ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
style={{ color: colors.textSecondary, fontSize: 16 }}
>
{title}: {title}:
</ThemedText> </ThemedText>
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
{description || "-"}
</ThemedText>
</ScrollView>
</View> </View>
); );
}; };

141
components/map/ShipInfo.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { STATUS_DANGEROUS, STATUS_NORMAL, STATUS_WARNING } from "@/constants";
import { useAppTheme } from "@/hooks/use-app-theme";
import { useI18n } from "@/hooks/use-i18n";
import { formatRelativeTime } from "@/services/time_service";
import { convertToDMS, kmhToKnot } from "@/utils/geom";
import { Ionicons } from "@expo/vector-icons";
import { ScrollView, Text, View } from "react-native";
import { ThemedText } from "../themed-text";
interface ShipInfoProps {
thingMetadata?: Model.ThingMetadata;
}
const ShipInfo = ({ thingMetadata }: ShipInfoProps) => {
const { t } = useI18n();
const { colors } = useAppTheme();
// Định nghĩa màu sắc theo trạng thái
const statusConfig = {
normal: {
dotColor: "bg-green-500",
badgeColor: "bg-green-100",
badgeTextColor: "text-green-700",
badgeText: "Bình thường",
},
warning: {
dotColor: "bg-yellow-500",
badgeColor: "bg-yellow-100",
badgeTextColor: "text-yellow-700",
badgeText: "Cảnh báo",
},
danger: {
dotColor: "bg-red-500",
badgeColor: "bg-red-100",
badgeTextColor: "text-red-700",
badgeText: "Nguy hiểm",
},
};
const getThingStatus = () => {
switch (thingMetadata?.state_level) {
case STATUS_NORMAL:
return "normal";
case STATUS_WARNING:
return "warning";
case STATUS_DANGEROUS:
return "danger";
default:
return "normal";
}
};
const gpsData: Model.GPSResponse = JSON.parse(thingMetadata?.gps || "{}");
const currentStatus = statusConfig[getThingStatus()];
// Format tọa độ
const formatCoordinate = (lat: number, lon: number) => {
const latDir = lat >= 0 ? "N" : "S";
const lonDir = lon >= 0 ? "E" : "W";
return `${Math.abs(lat).toFixed(4)}°${latDir}, ${Math.abs(lon).toFixed(
4
)}°${lonDir}`;
};
return (
<View className="px-4 py-3">
{/* Header: Tên tàu và trạng thái */}
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2">
{/* Status dot */}
<View className={`h-3 w-3 rounded-full ${currentStatus.dotColor}`} />
{/* Tên tàu */}
<Text className="text-lg font-semibold text-gray-900">
{thingMetadata?.ship_name}
</Text>
</View>
{/* Badge trạng thái */}
<View className={`px-3 py-1 rounded-full ${currentStatus.badgeColor}`}>
<Text
className={`text-md font-medium ${currentStatus.badgeTextColor}`}
>
{currentStatus.badgeText}
</Text>
</View>
</View>
{/* Mã tàu */}
{/* <Text className="text-base text-gray-600 mb-2">{shipCode}</Text> */}
<View className="flex-row items-center justify-between gap-2 mb-3">
<View className="flex-row items-center gap-2 w-2/3">
<Ionicons name="speedometer-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">
{kmhToKnot(gpsData.s || 0)} {t("home.speed_units")}
</Text>
</View>
<View className="flex-row items-start gap-2 w-1/3 ">
<Ionicons name="compass-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">{gpsData.h}°</Text>
</View>
</View>
{/* Tọa độ */}
<View className="flex-row items-center justify-between gap-2 mb-2">
<View className="flex-row items-center gap-2 w-2/3">
<Ionicons name="location-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">
{convertToDMS(gpsData.lat || 0, true)},
{convertToDMS(gpsData.lon || 0, false)}
</Text>
</View>
<View className=" flex-row items-start gap-2 w-1/3 ">
<Ionicons name="time-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">
{formatRelativeTime(gpsData.t)}
</Text>
</View>
</View>
{/* <View className="flex">
<Description title="Trạng thái" description={thingMetadata?.state} />
</View> */}
{thingMetadata?.state !== "" && (
<View className="flex-row items-center gap-2">
<ThemedText style={{ color: colors.textSecondary, fontSize: 15 }}>
Trạng thái:
</ThemedText>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ flex: 1 }}
>
<ThemedText style={{ color: colors.text, fontSize: 15 }}>
{thingMetadata?.state || "-"}
</ThemedText>
</ScrollView>
</View>
)}
</View>
);
};
export default ShipInfo;

165
components/map/TagState.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
import { ThemedText } from "../themed-text";
export type TagStateCallbackPayload = {
isNormal: boolean;
isWarning: boolean;
isDangerous: boolean;
isSos: boolean;
isDisconected: boolean; // giữ đúng theo yêu cầu (1 'n')
};
type TagStateProps = {
normalCount?: number;
warningCount?: number;
dangerousCount?: number;
sosCount?: number;
disconnectedCount?: number;
onTagPress?: (selection: TagStateCallbackPayload) => void;
className?: string;
};
export function TagState({
normalCount = 0,
warningCount = 0,
dangerousCount = 0,
sosCount = 0,
disconnectedCount = 0,
onTagPress,
className = "",
}: TagStateProps) {
// Quản lý trạng thái các tag ở cấp cha để trả về tổng hợp
const [activeStates, setActiveStates] = useState({
normal: false,
warning: false,
dangerous: false,
sos: false,
disconnected: false,
});
const toggleState = (state: keyof typeof activeStates) => {
setActiveStates((prev) => {
const next = { ...prev, [state]: !prev[state] };
// Gọi callback với object tổng hợp sau khi cập nhật
onTagPress?.({
isNormal: next.normal,
isWarning: next.warning,
isDangerous: next.dangerous,
isSos: next.sos,
isDisconected: next.disconnected,
});
return next;
});
};
const renderTag = (
state: "normal" | "warning" | "dangerous" | "sos" | "disconnected",
count: number,
label: string,
colors: {
defaultBg: string;
defaultBorder: string;
defaultText: string;
activeBg: string;
activeBorder: string;
activeText: string;
}
) => {
if (count === 0) return null;
const pressed = activeStates[state];
return (
<Pressable
key={state}
onPress={() => toggleState(state)}
style={[
styles.tag,
{
backgroundColor: pressed ? colors.activeBg : colors.defaultBg,
borderColor: pressed ? colors.activeBorder : colors.defaultBorder,
},
]}
>
<ThemedText
style={[
styles.tagText,
{
color: pressed ? colors.activeText : colors.defaultText,
},
]}
type="defaultSemiBold"
>
{label}: {count}
</ThemedText>
</Pressable>
);
};
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className={className} style={styles.container}>
{renderTag("normal", normalCount, "Bình thường", {
defaultBg: "#FFFFFF",
defaultBorder: "#22C55E",
defaultText: "#22C55E",
activeBg: "#22C55E",
activeBorder: "#22C55E",
activeText: "#FFFFFF",
})}
{renderTag("warning", warningCount, "Cảnh báo", {
defaultBg: "#FFFFFF",
defaultBorder: "#EAB308",
defaultText: "#EAB308",
activeBg: "#EAB308",
activeBorder: "#EAB308",
activeText: "#FFFFFF",
})}
{renderTag("dangerous", dangerousCount, "Nguy hiểm", {
defaultBg: "#FFFFFF",
defaultBorder: "#F97316",
defaultText: "#F97316",
activeBg: "#F97316",
activeBorder: "#F97316",
activeText: "#FFFFFF",
})}
{renderTag("sos", sosCount, "SOS", {
defaultBg: "#FFFFFF",
defaultBorder: "#EF4444",
defaultText: "#EF4444",
activeBg: "#EF4444",
activeBorder: "#EF4444",
activeText: "#FFFFFF",
})}
{renderTag("disconnected", disconnectedCount, "Mất kết nối", {
defaultBg: "#FFFFFF",
defaultBorder: "#6B7280",
defaultText: "#6B7280",
activeBg: "#6B7280",
activeBorder: "#6B7280",
activeText: "#FFFFFF",
})}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
gap: 8,
padding: 5,
},
tag: {
borderWidth: 1,
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 5,
minWidth: 100,
alignItems: "center",
},
tagText: {
fontSize: 14,
textAlign: "center",
},
});

View File

@@ -31,7 +31,7 @@ const MAPPING = {
xmark: "close", xmark: "close",
pencil: "edit", pencil: "edit",
trash: "delete", trash: "delete",
"square.stack.3d.up": "layers", "square.stack.3d.up.fill": "layers",
"bell.fill": "notifications", "bell.fill": "notifications",
} as IconMapping; } as IconMapping;

View File

@@ -21,6 +21,7 @@ export const EVENT_ALARM_DATA = "ALARM_DATA_EVENT";
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT"; export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT"; export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT"; export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
export const EVENT_SEARCH_THINGS = "SEARCH_THINGS_EVENT";
// Entity Contants // Entity Contants
export const ENTITY = { export const ENTITY = {
@@ -28,8 +29,14 @@ export const ENTITY = {
GPS: "50:1", GPS: "50:1",
}; };
export const STATUS_NORMAL = 0;
export const STATUS_WARNING = 1;
export const STATUS_DANGEROUS = 2;
export const STATUS_SOS = 3;
// API Path Constants // API Path Constants
export const API_PATH_LOGIN = "/api/tokens"; export const API_PATH_LOGIN = "/api/tokens";
export const API_PATH_SEARCH_THINGS = "/api/things/search";
export const API_PATH_ENTITIES = "/api/io/entities"; export const API_PATH_ENTITIES = "/api/io/entities";
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo"; export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist"; export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
@@ -44,3 +51,5 @@ export const API_UPDATE_FISHING_LOGS = "/api/sgw/fishingLog";
export const API_SOS = "/api/sgw/sos"; export const API_SOS = "/api/sgw/sos";
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints"; export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
export const API_GET_ALL_BANZONES = "/api/sgw/banzones"; export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
export const API_GET_SHIP_TYPES = "/api/sgw/ships/types";
export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup";

View File

@@ -2,7 +2,10 @@ import { api } from "@/config";
import { import {
API_GET_ALARMS, API_GET_ALARMS,
API_GET_GPS, API_GET_GPS,
API_GET_SHIP_GROUPS,
API_GET_SHIP_TYPES,
API_PATH_ENTITIES, API_PATH_ENTITIES,
API_PATH_SEARCH_THINGS,
API_PATH_SHIP_TRACK_POINTS, API_PATH_SHIP_TRACK_POINTS,
API_SOS, API_SOS,
} from "@/constants"; } from "@/constants";
@@ -35,3 +38,15 @@ export async function queryDeleteSos() {
export async function querySendSosMessage(message: string) { export async function querySendSosMessage(message: string) {
return await api.put<Model.SosRequest>(API_SOS, { message }); return await api.put<Model.SosRequest>(API_SOS, { message });
} }
export async function querySearchThings(body: Model.SearchThingBody) {
return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body);
}
export async function queryShipTypes() {
return await api.get<Model.ShipType[]>(API_GET_SHIP_TYPES);
}
export async function queryShipGroups() {
return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS);
}

View File

@@ -15,6 +15,7 @@ declare namespace Model {
s: number; s: number;
h: number; h: number;
fishing: boolean; fishing: boolean;
t: number;
} }
interface Alarm { interface Alarm {
name: string; name: string;
@@ -52,7 +53,7 @@ declare namespace Model {
// Banzones // Banzones
// Banzone // Banzone
export interface Zone { interface Zone {
id?: string; id?: string;
name?: string; name?: string;
type?: number; type?: number;
@@ -62,7 +63,7 @@ declare namespace Model {
geom?: Geom; geom?: Geom;
} }
export interface Condition { interface Condition {
max?: number; max?: number;
min?: number; min?: number;
type?: Type; type?: Type;
@@ -70,12 +71,12 @@ declare namespace Model {
from?: number; from?: number;
} }
export enum Type { enum Type {
LengthLimit = "length_limit", LengthLimit = "length_limit",
MonthRange = "month_range", MonthRange = "month_range",
} }
export interface Geom { interface Geom {
geom_type?: number; geom_type?: number;
geom_poly?: string; geom_poly?: string;
geom_lines?: string; geom_lines?: string;
@@ -211,4 +212,92 @@ declare namespace Model {
cites_appendix: any; cites_appendix: any;
vn_law: boolean; vn_law: boolean;
} }
// Seagateway Owner App
// Thing
interface SearchThingBody {
offset?: number;
limit?: number;
order?: string;
dir?: "asc" | "desc";
name?: string;
metadata?: any;
}
interface ThingsResponse {
total?: number;
offset?: number;
limit?: number;
order?: string;
direction?: string;
metadata?: ThingsResponseMetadata;
things?: Thing[];
}
interface ThingsResponseMetadata {
total_connected?: number;
total_filter?: number;
total_sos?: number;
total_state_level_0?: number;
total_state_level_1?: number;
total_state_level_2?: number;
total_thing?: number;
}
interface Thing {
id?: string;
name?: string;
key?: string;
metadata?: ThingMetadata;
}
interface ThingMetadata {
address?: string;
alarm_list?: string;
basename?: string;
cfg_channel_id?: string;
cfg_id?: string;
connected?: boolean;
ctrl_channel_id?: string;
data_channel_id?: string;
enduser?: string;
external_id?: string;
gps?: string;
gps_time?: string;
group_id?: string;
req_channel_id?: string;
ship_group_id?: string;
ship_id?: string;
ship_length?: string;
ship_name?: string;
ship_power?: string;
ship_reg_number?: string;
ship_type?: string;
sos?: string;
sos_time?: string;
state?: string;
state_level?: number;
state_updated_time?: number;
trip_state?: string;
type?: string;
updated_time?: number;
uptime?: number;
zone_approaching_alarm_list?: string;
zone_entered_alarm_list?: string;
zone_fishing_alarm_list?: string;
}
// Ship
interface ShipType {
id?: number;
name?: string;
description?: string;
}
interface ShipGroup {
id?: string;
name?: string;
owner_id?: string;
description?: string;
}
} }

View File

@@ -41,6 +41,7 @@
"latitude": "Latitude", "latitude": "Latitude",
"longitude": "Longitude", "longitude": "Longitude",
"speed": "Speed", "speed": "Speed",
"speed_units": "knots",
"heading": "Heading", "heading": "Heading",
"offline": "Offline", "offline": "Offline",
"online": "Online", "online": "Online",

View File

@@ -41,6 +41,7 @@
"latitude": "Vĩ độ", "latitude": "Vĩ độ",
"longitude": "Kinh độ", "longitude": "Kinh độ",
"speed": "Tốc độ", "speed": "Tốc độ",
"speed_units": "hải lý/giờ",
"heading": "Hướng", "heading": "Hướng",
"offline": "Ngoại tuyến", "offline": "Ngoại tuyến",
"online": "Trực tuyến", "online": "Trực tuyến",

8
package-lock.json generated
View File

@@ -41,7 +41,7 @@
"react": "19.1.0", "react": "19.1.0",
"react-aria": "^3.44.0", "react-aria": "^3.44.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.67.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-keyboard-aware-scroll-view": "^0.9.5",
@@ -13295,9 +13295,9 @@
} }
}, },
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.66.0", "version": "7.67.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.67.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "integrity": "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"

View File

@@ -44,7 +44,7 @@
"react": "19.1.0", "react": "19.1.0",
"react-aria": "^3.44.0", "react-aria": "^3.44.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.67.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-keyboard-aware-scroll-view": "^0.9.5",

View File

@@ -4,12 +4,14 @@ import {
EVENT_BANZONE_DATA, EVENT_BANZONE_DATA,
EVENT_ENTITY_DATA, EVENT_ENTITY_DATA,
EVENT_GPS_DATA, EVENT_GPS_DATA,
EVENT_SEARCH_THINGS,
EVENT_TRACK_POINTS_DATA, EVENT_TRACK_POINTS_DATA,
} from "@/constants"; } from "@/constants";
import { import {
queryAlarm, queryAlarm,
queryEntities, queryEntities,
queryGpsData, queryGpsData,
querySearchThings,
queryTrackPoints, queryTrackPoints,
} from "@/controller/DeviceController"; } from "@/controller/DeviceController";
import { queryBanzones } from "@/controller/MapController"; import { queryBanzones } from "@/controller/MapController";
@@ -21,12 +23,14 @@ const intervals: {
entities: ReturnType<typeof setInterval> | null; entities: ReturnType<typeof setInterval> | null;
trackPoints: ReturnType<typeof setInterval> | null; trackPoints: ReturnType<typeof setInterval> | null;
banzones: ReturnType<typeof setInterval> | null; banzones: ReturnType<typeof setInterval> | null;
searchThings: ReturnType<typeof setInterval> | null;
} = { } = {
gps: null, gps: null,
alarm: null, alarm: null,
entities: null, entities: null,
trackPoints: null, trackPoints: null,
banzones: null, banzones: null,
searchThings: null,
}; };
export function getGpsEventBus() { export function getGpsEventBus() {
@@ -153,6 +157,37 @@ export function getBanzonesEventBus() {
}, AUTO_REFRESH_INTERVAL * 60); }, AUTO_REFRESH_INTERVAL * 60);
} }
export function searchThingEventBus(body: Model.SearchThingBody) {
// Clear interval cũ nếu có
if (intervals.searchThings) {
clearInterval(intervals.searchThings);
intervals.searchThings = null;
}
const searchThingsData = async () => {
// console.log("call api with body:", body);
try {
const resp = await querySearchThings(body);
if (resp && resp.data) {
eventBus.emit(EVENT_SEARCH_THINGS, resp.data);
} else {
console.log("SearchThings: no data returned");
}
} catch (err) {
console.error("SearchThings: fetch error", err);
}
};
// Gọi ngay lần đầu
searchThingsData();
// Sau đó setup interval để gọi lại mỗi 30s
intervals.searchThings = setInterval(() => {
searchThingsData();
}, AUTO_REFRESH_INTERVAL * 6); // 30 seconds
}
export function stopEvents() { export function stopEvents() {
Object.keys(intervals).forEach((k) => { Object.keys(intervals).forEach((k) => {
const key = k as keyof typeof intervals; const key = k as keyof typeof intervals;

43
services/time_service.tsx Normal file
View File

@@ -0,0 +1,43 @@
import dayjs from "dayjs";
import "dayjs/locale/vi";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
dayjs.locale("vi");
export { dayjs };
/**
* Chuyển đổi unix timestamp thành text thời gian tương đối
* @param unixTime - Unix timestamp (seconds hoặc milliseconds)
* @returns String mô tả thời gian tương đối (vd: "5 phút trước", "2 giờ trước")
*/
export function formatRelativeTime(unixTime: number): string {
if (!unixTime || unixTime <= 0) return "Không rõ";
// Xác định đơn vị timestamp (seconds hoặc milliseconds)
const timestamp = unixTime < 10000000000 ? unixTime * 1000 : unixTime;
const updateDate = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - updateDate.getTime();
// Nếu thời gian trong tương lai
if (diffMs < 0) return "Vừa xong";
const diffSeconds = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffSeconds < 60) return "Vừa xong";
if (diffMins < 60) return `${diffMins} phút trước`;
if (diffHours < 24) return `${diffHours} giờ trước`;
if (diffDays < 7) return `${diffDays} ngày trước`;
if (diffWeeks < 4) return `${diffWeeks} tuần trước`;
if (diffMonths < 12) return `${diffMonths} tháng trước`;
return `${diffYears} năm trước`;
}

24
state/use-ship-types.ts Normal file
View File

@@ -0,0 +1,24 @@
import { queryShipTypes } from "@/controller/DeviceController";
import { create } from "zustand";
type ShipType = {
shipTypes: Model.ShipType[] | [];
getShipTypes: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useShipTypes = create<ShipType>((set) => ({
shipTypes: [],
getShipTypes: async () => {
try {
const response = await queryShipTypes();
set({ shipTypes: response.data, loading: false });
} catch (error) {
console.error("Error when fetch shipTypes: ", error);
set({ error: "Failed to fetch shipTypes data", loading: false });
set({ shipTypes: [] });
}
},
error: null,
}));

31
state/use-thing.ts Normal file
View File

@@ -0,0 +1,31 @@
import { querySearchThings } from "@/controller/DeviceController";
import { create } from "zustand";
type ThingState = {
things: Model.Thing[] | null;
getThings: (body: Model.SearchThingBody) => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useThings = create<ThingState>((set) => ({
things: null,
getThings: async (body: Model.SearchThingBody) => {
set({ loading: true, error: null });
try {
const response = await querySearchThings(body);
console.log("Things fetching API: ", response.data.things?.length);
set({ things: response.data.things ?? [], loading: false });
} catch (error) {
console.error("Error when fetch things: ", error);
set({
error: "Failed to fetch things data",
loading: false,
things: null,
});
}
},
error: null,
loading: false,
}));