Compare commits

...

2 Commits

12 changed files with 373 additions and 306 deletions

View File

@@ -7,16 +7,7 @@ export default function Warning() {
const [isShowModal, setIsShowModal] = useState(false); const [isShowModal, setIsShowModal] = useState(false);
return ( return (
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1 }}>
<View style={styles.container}>
<CreateOrUpdateHaulModal
fishingLog={fishingLogData}
isVisible={isShowModal}
onClose={function (): void {
setIsShowModal(false);
}}
/>
<Button title="Thêm thu hoạch" onPress={() => setIsShowModal(true)} />
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -54,118 +45,3 @@ const styles = StyleSheet.create({
}, },
}); });
const fishingLogData:Model.FishingLog = {
fishing_log_id: "124b2701-a5d6-4eb0-ba3b-6789473c14a9",
trip_id: "d84caab6-ebb0-4cf7-abf9-31e5617d23b9",
start_at: "2025-11-07T10:50:01.693193764Z",
end_at: "2025-11-07T10:50:31.693027729Z",
start_lat: 11.59141,
start_lon: 109.0489,
haul_lat: 11.590274,
haul_lon: 109.049284,
status: 1,
weather_description: "Stormy",
info: [
{
fish_species_id: 8,
fish_name: "Cá hồng phớn",
catch_number: 1309,
catch_unit: "kg",
fish_size: 173,
fish_rarity: 3,
fish_condition: "Còn sống",
gear_usage: "Câu vàng",
},
{
fish_species_id: 18,
fish_name: "Cá đuối quạt",
catch_number: 731,
catch_unit: "kg",
fish_size: 16,
fish_rarity: 4,
fish_condition: "Chết",
gear_usage: "Bẫy lưới",
},
{
fish_species_id: 7,
fish_name: "Cá bơn vàng",
catch_number: 1224,
catch_unit: "kg",
fish_size: 12,
fish_rarity: 1,
fish_condition: "Chết",
gear_usage: "",
},
{
fish_species_id: 16,
fish_name: "Cá rồng biển",
catch_number: 838,
catch_unit: "kg",
fish_size: 164,
fish_rarity: 3,
fish_condition: "Chết",
gear_usage: "Lưới rê",
},
{
fish_species_id: 9,
fish_name: "Cá hổ Napoleon",
catch_number: 1410,
catch_unit: "kg",
fish_size: 104,
fish_rarity: 4,
fish_condition: "Bị thương",
gear_usage: "Câu vàng",
},
{
fish_species_id: 3,
fish_name: "Cá chim trắng",
catch_number: 1184,
catch_unit: "kg",
fish_size: 104,
fish_rarity: 2,
fish_condition: "Còn sống",
gear_usage: "",
},
{
fish_species_id: 5,
fish_name: "Cá mú đỏ",
catch_number: 987,
catch_unit: "kg",
fish_size: 171,
fish_rarity: 2,
fish_condition: "Bị thương",
gear_usage: "",
},
{
fish_species_id: 13,
fish_name: "Cá song đỏ",
catch_number: 1676,
catch_unit: "kg",
fish_size: 99,
fish_rarity: 2,
fish_condition: "Bị thương",
gear_usage: "",
},
{
fish_species_id: 11,
fish_name: "Cá ngừ đại dương",
catch_number: 462,
catch_unit: "kg",
fish_size: 11,
fish_rarity: 1,
fish_condition: "Bị thương",
gear_usage: "",
},
{
fish_species_id: 2,
fish_name: "Cá nục",
catch_number: 496,
catch_unit: "kg",
fish_size: 125,
fish_rarity: 1,
fish_condition: "Còn sống",
gear_usage: "",
},
],
sync: true,
};

View File

@@ -46,8 +46,9 @@ export default function HomeScreen() {
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] = const [polylineCoordinates, setPolylineCoordinates] = useState<
useState<PolylineWithLabelProps | null>(null); PolylineWithLabelProps[]
>([]);
const [polygonCoordinates, setPolygonCoordinates] = useState< const [polygonCoordinates, setPolygonCoordinates] = useState<
PolygonWithLabelProps[] PolygonWithLabelProps[]
>([]); >([]);
@@ -69,7 +70,7 @@ export default function HomeScreen() {
} else { } else {
setGpsData(null); setGpsData(null);
setPolygonCoordinates([]); setPolygonCoordinates([]);
setPolylineCoordinates(null); setPolylineCoordinates([]);
} }
}; };
const queryAlarmData = (alarmData: Model.AlarmResponse) => { const queryAlarmData = (alarmData: Model.AlarmResponse) => {
@@ -121,7 +122,7 @@ export default function HomeScreen() {
}, []); }, []);
useEffect(() => { useEffect(() => {
setPolylineCoordinates(null); setPolylineCoordinates([]);
setPolygonCoordinates([]); setPolygonCoordinates([]);
if (!entityData) return; if (!entityData) return;
if (!banzoneData) return; if (!banzoneData) return;
@@ -139,11 +140,14 @@ export default function HomeScreen() {
} }
// Nếu danh sách zone rỗng, clear tất cả // Nếu danh sách zone rỗng, clear tất cả
if (zones.length === 0) { if (zones.length === 0) {
setPolylineCoordinates(null); setPolylineCoordinates([]);
setPolygonCoordinates([]); setPolygonCoordinates([]);
return; return;
} }
let polylines: PolylineWithLabelProps[] = [];
let polygons: PolygonWithLabelProps[] = [];
for (const zone of zones) { for (const zone of zones) {
// console.log("Zone Data: ", zone); // console.log("Zone Data: ", zone);
const geom = banzoneData.find((b) => b.id === zone.zone_id); const geom = banzoneData.find((b) => b.id === zone.zone_id);
@@ -161,7 +165,7 @@ export default function HomeScreen() {
geom_lines || "" geom_lines || ""
); );
if (coordinates.length > 0) { if (coordinates.length > 0) {
setPolylineCoordinates({ polylines.push({
coordinates: coordinates.map((coord) => ({ coordinates: coordinates.map((coord) => ({
latitude: coord[0], latitude: coord[0],
longitude: coord[1], longitude: coord[1],
@@ -169,25 +173,31 @@ export default function HomeScreen() {
label: zone?.zone_name ?? "", label: zone?.zone_name ?? "",
content: zone?.message ?? "", content: zone?.message ?? "",
}); });
} else {
console.log("Không tìm thấy polyline trong alarm");
} }
} else if (geom_type === 1) { } else if (geom_type === 1) {
// foundPolygon = true; // foundPolygon = true;
const coordinates = convertWKTtoLatLngString(geom_poly || ""); const coordinates = convertWKTtoLatLngString(geom_poly || "");
if (coordinates.length > 0) { if (coordinates.length > 0) {
// console.log("Polygon Coordinate: ", coordinates); // console.log("Polygon Coordinate: ", coordinates);
setPolygonCoordinates( const zonePolygons = coordinates.map((polygon) => ({
coordinates.map((polygon) => ({
coordinates: polygon.map((coord) => ({ coordinates: polygon.map((coord) => ({
latitude: coord[0], latitude: coord[0],
longitude: coord[1], longitude: coord[1],
})), })),
label: zone?.zone_name ?? "", label: zone?.zone_name ?? "",
content: zone?.message ?? "", content: zone?.message ?? "",
})) }));
); polygons.push(...zonePolygons);
} else {
console.log("Không tìm thấy polygon trong alarm");
} }
} }
} }
setPolylineCoordinates(polylines);
setPolygonCoordinates(polygons);
} }
}, [banzoneData, entityData]); }, [banzoneData, entityData]);
@@ -301,7 +311,7 @@ export default function HomeScreen() {
// console.log(`Rendering circle ${index}:`, point); // console.log(`Rendering circle ${index}:`, point);
return ( return (
<Circle <Circle
key={index} key={`circle-${index}`}
center={{ center={{
latitude: point.lat, latitude: point.lat,
longitude: point.lon, longitude: point.lon,
@@ -315,27 +325,27 @@ export default function HomeScreen() {
/> />
); );
})} })}
{polylineCoordinates && ( {polylineCoordinates.length > 0 && (
<>
{polylineCoordinates.map((polyline, index) => (
<PolylineWithLabel <PolylineWithLabel
key={`polyline-${gpsData?.lat || 0}-${gpsData?.lon || 0}`} key={`polyline-${index}-${gpsData?.lat || 0}-${
coordinates={polylineCoordinates.coordinates} gpsData?.lon || 0
label={polylineCoordinates.label} }`}
content={polylineCoordinates.content} coordinates={polyline.coordinates}
label={polyline.label}
content={polyline.content}
strokeColor="#FF5733" strokeColor="#FF5733"
strokeWidth={4} strokeWidth={4}
showDistance={false} showDistance={false}
// zIndex={50} // zIndex={50}
/> />
))}
</>
)} )}
{polygonCoordinates.length > 0 && ( {polygonCoordinates.length > 0 && (
<> <>
{polygonCoordinates.map((polygon, index) => { {polygonCoordinates.map((polygon, index) => {
// Tạo key ổn định từ tọa độ đầu tiên của polygon
const polygonKey =
polygon.coordinates.length > 0
? `polygon-${polygon.coordinates[0].latitude}-${polygon.coordinates[0].longitude}-${index}`
: `polygon-${index}`;
return ( return (
<PolygonWithLabel <PolygonWithLabel
key={`polygon-${index}-${gpsData?.lat || 0}-${ key={`polygon-${index}-${gpsData?.lat || 0}-${
@@ -365,11 +375,6 @@ export default function HomeScreen() {
latitude: gpsData.lat, latitude: gpsData.lat,
longitude: gpsData.lon, longitude: gpsData.lon,
}} }}
title={
platform === IOS_PLATFORM
? "Tàu của mình - iOS"
: "Tàu của mình - Android"
}
zIndex={20} zIndex={20}
anchor={ anchor={
platform === IOS_PLATFORM platform === IOS_PLATFORM

View File

@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view"; import { ThemedView } from "@/components/themed-view";
import { api } from "@/config"; import { api } from "@/config";
import { TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { removeStorageItem } from "@/utils/storage"; import { removeStorageItem } from "@/utils/storage";
import { useState } from "react"; import { useState } from "react";
@@ -37,9 +37,10 @@ export default function SettingScreen() {
<ThemedText type="title">Settings</ThemedText> <ThemedText type="title">Settings</ThemedText>
<ThemedView <ThemedView
style={styles.button} style={styles.button}
onTouchEnd={() => { onTouchEnd={async () => {
removeStorageItem(TOKEN); await removeStorageItem(TOKEN);
router.replace("/auth/login"); await removeStorageItem(DOMAIN);
router.navigate("/auth/login");
}} }}
> >
<ThemedText type="defaultSemiBold">Đăng xuất</ThemedText> <ThemedText type="defaultSemiBold">Đăng xuất</ThemedText>

View File

@@ -1,14 +1,16 @@
import ScanQRCode from "@/components/ScanQRCode";
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view"; import { ThemedView } from "@/components/themed-view";
import { TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { login } from "@/controller/AuthController"; import { login } from "@/controller/AuthController";
import { showErrorToast } from "@/services/toast_service"; import { showErrorToast, showWarningToast } from "@/services/toast_service";
import { import {
getStorageItem, getStorageItem,
removeStorageItem, removeStorageItem,
setStorageItem, setStorageItem,
} from "@/utils/storage"; } from "@/utils/storage";
import { parseJwtToken } from "@/utils/token"; import { parseJwtToken } from "@/utils/token";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
@@ -29,22 +31,30 @@ export default function LoginScreen() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [isShowingQRScanner, setIsShowingQRScanner] = useState(false);
const checkLogin = useCallback(async () => { const checkLogin = useCallback(async () => {
const token = await getStorageItem(TOKEN); const token = await getStorageItem(TOKEN);
console.log("Token:", token); const domain = await getStorageItem(DOMAIN);
// console.log("Token:", token);
// removeStorageItem(DOMAIN);
console.log("Domain:", domain);
if (!token) { if (!token) {
return; return;
} }
if (!domain) {
return;
}
const parsed = parseJwtToken(token); const parsed = parseJwtToken(token);
console.log("Parse Token: ", parsed); // console.log("Parse Token: ", parsed);
const { exp } = parsed; const { exp } = parsed;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60; const oneHour = 60 * 60;
if (exp - now < oneHour) { if (exp - now < oneHour) {
await removeStorageItem(TOKEN); await removeStorageItem(TOKEN);
await removeStorageItem(DOMAIN);
} else { } else {
router.replace("/(tabs)"); router.replace("/(tabs)");
} }
@@ -54,9 +64,41 @@ export default function LoginScreen() {
checkLogin(); checkLogin();
}, [checkLogin]); }, [checkLogin]);
const handleLogin = async () => { const handleQRCodeScanned = async (data: string) => {
console.log("QR Code Scanned Data:", data);
try {
const parsed = JSON.parse(data);
if (parsed.username && parsed.password) {
// update UI fields
setUsername(parsed.username);
setPassword(parsed.password);
console.log("Domain: ", parsed.device_ip);
// close scanner so user sees the filled form
await setStorageItem(DOMAIN, parsed.device_ip);
// // call login directly with scanned credentials to avoid waiting for state to update
await handleLogin({
username: parsed.username,
password: parsed.password,
});
} else {
showWarningToast("Mã QR không hợp lệ");
}
} catch (error) {
showWarningToast("Mã QR không hợp lệ");
}
};
const handleLogin = async (creds?: {
username: string;
password: string;
}) => {
const user = creds?.username ?? username;
const pass = creds?.password ?? password;
// Validate input // Validate input
if (!username.trim() || !password.trim()) { if (!user?.trim() || !pass?.trim()) {
showErrorToast("Vui lòng nhập tài khoản và mật khẩu"); showErrorToast("Vui lòng nhập tài khoản và mật khẩu");
return; return;
} }
@@ -64,8 +106,8 @@ export default function LoginScreen() {
setLoading(true); setLoading(true);
try { try {
const body: Model.LoginRequestBody = { const body: Model.LoginRequestBody = {
username: username, username: user,
password: password, password: pass,
}; };
const response = await login(body); const response = await login(body);
@@ -131,25 +173,56 @@ export default function LoginScreen() {
{/* Password Input */} {/* Password Input */}
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<ThemedText style={styles.label}>Mật khẩu</ThemedText> <ThemedText style={styles.label}>Mật khẩu</ThemedText>
<View className="relative">
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Nhập mật khẩu" placeholder="Nhập mật khẩu"
placeholderTextColor="#999" placeholderTextColor="#999"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
secureTextEntry secureTextEntry={!showPassword}
editable={!loading} editable={!loading}
autoCapitalize="none" autoCapitalize="none"
/> />
{/* Position absolute with top:0 and bottom:0 and justifyContent:center
ensures the icon remains vertically centered inside the input */}
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={{
position: "absolute",
right: 12,
top: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
padding: 4,
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons
name={showPassword ? "eye-off" : "eye"}
size={22}
color="#666"
/>
</TouchableOpacity>
</View>
</View> </View>
{/* Login Button */} {/* Login Button (3/4) + QR Scan (1/4) */}
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
}}
>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.loginButton, styles.loginButton,
loading && styles.loginButtonDisabled, loading && styles.loginButtonDisabled,
{ flex: 5, marginRight: 12, marginTop: 0 },
]} ]}
onPress={handleLogin} onPress={() => handleLogin()}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
@@ -159,6 +232,29 @@ export default function LoginScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={{
flex: 1,
paddingVertical: 10,
marginTop: 0,
borderColor: "#ddd",
borderWidth: 1,
borderRadius: 8,
backgroundColor: "transparent",
justifyContent: "center",
alignItems: "center",
}}
onPress={() => setIsShowingQRScanner(true)}
disabled={loading}
>
<MaterialIcons
name="qr-code-scanner"
size={28}
color="#007AFF"
/>
</TouchableOpacity>
</View>
{/* Footer text */} {/* Footer text */}
<View style={styles.footerContainer}> <View style={styles.footerContainer}>
<ThemedText style={styles.footerText}> <ThemedText style={styles.footerText}>
@@ -176,6 +272,11 @@ export default function LoginScreen() {
</View> </View>
</ThemedView> </ThemedView>
</ScrollView> </ScrollView>
<ScanQRCode
visible={isShowingQRScanner}
onClose={() => setIsShowingQRScanner(false)}
onScanned={handleQRCodeScanned}
/>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
} }

View File

@@ -23,6 +23,8 @@ export default function ScanQRCode({
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false); const [scanned, setScanned] = useState(false);
const cameraRef = useRef(null); const cameraRef = useRef(null);
// Dùng ref để chặn quét nhiều lần trong cùng một frame/event loop
const hasScannedRef = useRef(false);
// Request camera permission when component mounts or when visible changes to true // Request camera permission when component mounts or when visible changes to true
useEffect(() => { useEffect(() => {
@@ -38,6 +40,13 @@ export default function ScanQRCode({
} }
}, [visible]); }, [visible]);
// Mỗi khi reset scanned state thì reset luôn ref guard
useEffect(() => {
if (!scanned) {
hasScannedRef.current = false;
}
}, [scanned]);
const handleBarCodeScanned = ({ const handleBarCodeScanned = ({
type, type,
data, data,
@@ -45,11 +54,12 @@ export default function ScanQRCode({
type: string; type: string;
data: string; data: string;
}) => { }) => {
if (!scanned) { // Nếu đã scan rồi, bỏ qua
if (hasScannedRef.current || scanned) return;
hasScannedRef.current = true;
setScanned(true); setScanned(true);
onScanned(data); onScanned(data);
onClose(); onClose();
}
}; };
if (!permission) { if (!permission) {
@@ -102,7 +112,8 @@ export default function ScanQRCode({
<CameraView <CameraView
ref={cameraRef} ref={cameraRef}
style={styles.camera} style={styles.camera}
onBarcodeScanned={handleBarCodeScanned} // Chỉ gắn handler khi chưa scan để ngắt lắng nghe ngay lập tức sau khi quét thành công
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
barcodeScannerSettings={{ barcodeScannerSettings={{
barcodeTypes: ["qr"], barcodeTypes: ["qr"],
}} }}

View File

@@ -1,9 +1,9 @@
import { showToastError } from "@/config";
import { import {
queryDeleteSos, queryDeleteSos,
queryGetSos, queryGetSos,
querySendSosMessage, querySendSosMessage,
} from "@/controller/DeviceController"; } from "@/controller/DeviceController";
import { showErrorToast } from "@/services/toast_service";
import { sosMessage } from "@/utils/sosUtils"; import { sosMessage } from "@/utils/sosUtils";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -108,7 +108,7 @@ const SosButton = () => {
} }
} catch (error) { } catch (error) {
console.error("Error when send sos: ", error); console.error("Error when send sos: ", error);
showToastError("Không thể gửi tín hiệu SOS", "Lỗi"); showErrorToast("Không thể gửi tín hiệu SOS");
} }
}; };

View File

@@ -96,7 +96,12 @@ 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,
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
]}
/>
<TouchableOpacity <TouchableOpacity
onPress={() => handleStatusPress(item.fishing_log_id)} onPress={() => handleStatusPress(item.fishing_log_id)}
> >
@@ -125,7 +130,12 @@ 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,
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
]}
/>
<TouchableOpacity <TouchableOpacity
onPress={() => handleStatusPress(item.fishing_log_id)} onPress={() => handleStatusPress(item.fishing_log_id)}
> >

View File

@@ -36,6 +36,14 @@ const UNITS_OPTIONS = UNITS.map((unit) => ({
value: unit.toString(), value: unit.toString(),
})); }));
const SIZE_UNITS = ["cm", "m"] as const;
type SizeUnit = (typeof SIZE_UNITS)[number];
const SIZE_UNITS_OPTIONS = SIZE_UNITS.map((unit) => ({
label: unit,
value: unit,
}));
// Zod schema cho 1 dòng cá // Zod schema cho 1 dòng cá
const fishItemSchema = z.object({ const fishItemSchema = z.object({
id: z.number().min(1, "Chọn loài cá"), id: z.number().min(1, "Chọn loài cá"),
@@ -47,6 +55,7 @@ const fishItemSchema = z.object({
.number({ invalid_type_error: "Kích thước phải là số" }) .number({ invalid_type_error: "Kích thước phải là số" })
.positive("Kích thước > 0") .positive("Kích thước > 0")
.optional(), .optional(),
sizeUnit: z.enum(SIZE_UNITS),
}); });
// Schema tổng: mảng các item // Schema tổng: mảng các item
@@ -60,6 +69,7 @@ const defaultItem = (): FormValues["fish"][number] => ({
quantity: 1, quantity: 1,
unit: "con", unit: "con",
size: undefined, size: undefined,
sizeUnit: "cm",
}); });
const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
@@ -210,6 +220,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
quantity: (h.catch_number as number) ?? 1, quantity: (h.catch_number as number) ?? 1,
unit: (h.catch_unit as Unit) ?? (defaultItem().unit as Unit), unit: (h.catch_unit as Unit) ?? (defaultItem().unit as Unit),
size: (h.fish_size as number) ?? undefined, size: (h.fish_size as number) ?? undefined,
sizeUnit: "cm" as SizeUnit,
})); }));
reset({ fish: mapped as any }); reset({ fish: mapped as any });
setIsCreateMode(false); setIsCreateMode(false);
@@ -225,9 +236,11 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
}, [isVisible, fishingLog?.info, reset]); }, [isVisible, fishingLog?.info, reset]);
const renderRow = (item: any, index: number) => { const renderRow = (item: any, index: number) => {
const isExpanded = expandedFishIndices.includes(index); const isExpanded = expandedFishIndices.includes(index);
// Give expanded card highest zIndex, others get decreasing zIndex based on position
const cardZIndex = isExpanded ? 1000 : 100 - index;
return ( return (
<View key={item._id} style={styles.fishCard}> <View key={item._id} style={[styles.fishCard, { zIndex: cardZIndex }]}>
{/* Delete + Chevron buttons - top right corner */} {/* Delete + Chevron buttons - top right corner */}
<View <View
style={{ style={{
@@ -315,13 +328,13 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
{/* Form - visible when expanded */} {/* Form - visible when expanded */}
{isExpanded && ( {isExpanded && (
<View style={{ paddingRight: 100 }}> <View style={{ paddingRight: 10 }}>
{/* Species dropdown */} {/* Species dropdown */}
<Controller <Controller
control={control} control={control}
name={`fish.${index}.id`} name={`fish.${index}.id`}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<View style={styles.fieldGroup}> <View style={[styles.fieldGroup, { marginTop: 20 }]}>
<Text style={styles.label}>Tên </Text> <Text style={styles.label}>Tên </Text>
<Select <Select
options={fishSpecies!.map((fish) => ({ options={fishSpecies!.map((fish) => ({
@@ -342,7 +355,9 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
)} )}
/> />
{/* Quantity */} {/* Số lượng & Đơn vị cùng hàng */}
<View style={{ flexDirection: "row", gap: 12 }}>
<View style={{ flex: 1 }}>
<Controller <Controller
control={control} control={control}
name={`fish.${index}.quantity`} name={`fish.${index}.quantity`}
@@ -356,7 +371,10 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
onChangeText={(t) => onChangeText={(t) =>
onChange(Number(t.replace(/,/g, ".")) || 0) onChange(Number(t.replace(/,/g, ".")) || 0)
} }
style={[styles.input, !isEditing && styles.inputDisabled]} style={[
styles.input,
!isEditing && styles.inputDisabled,
]}
editable={isEditing} editable={isEditing}
/> />
{errors.fish?.[index]?.quantity && ( {errors.fish?.[index]?.quantity && (
@@ -367,8 +385,8 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
</View> </View>
)} )}
/> />
</View>
{/* Unit dropdown */} <View style={{ flex: 1 }}>
<Controller <Controller
control={control} control={control}
name={`fish.${index}.unit`} name={`fish.${index}.unit`}
@@ -394,14 +412,18 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
</View> </View>
)} )}
/> />
</View>
</View>
{/* Size (optional) */} {/* Size (optional) + Unit dropdown */}
<View style={{ flexDirection: "row", gap: 12 }}>
<View style={{ flex: 1 }}>
<Controller <Controller
control={control} control={control}
name={`fish.${index}.size`} name={`fish.${index}.size`}
render={({ field: { value, onChange, onBlur } }) => ( render={({ field: { value, onChange, onBlur } }) => (
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={styles.label}>Kích thước (cm) tùy chọn</Text> <Text style={styles.label}>Kích thước</Text>
<TextInput <TextInput
keyboardType="numeric" keyboardType="numeric"
value={value ? String(value) : ""} value={value ? String(value) : ""}
@@ -409,7 +431,10 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
onChangeText={(t) => onChangeText={(t) =>
onChange(t ? Number(t.replace(/,/g, ".")) : undefined) onChange(t ? Number(t.replace(/,/g, ".")) : undefined)
} }
style={[styles.input, !isEditing && styles.inputDisabled]} style={[
styles.input,
!isEditing && styles.inputDisabled,
]}
editable={isEditing} editable={isEditing}
/> />
{errors.fish?.[index]?.size && ( {errors.fish?.[index]?.size && (
@@ -421,6 +446,27 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
)} )}
/> />
</View> </View>
<View style={{ flex: 1 }}>
<Controller
control={control}
name={`fish.${index}.sizeUnit`}
render={({ field: { value, onChange } }) => (
<View style={styles.fieldGroup}>
<Text style={styles.label}>Đơn vị</Text>
<Select
options={SIZE_UNITS_OPTIONS}
value={value}
onChange={onChange}
placeholder="Chọn đơn vị"
disabled={!isEditing}
listStyle={{ maxHeight: 80 }}
/>
</View>
)}
/>
</View>
</View>
</View>
)} )}
</View> </View>
); );
@@ -447,15 +493,20 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
<View style={styles.headerButtons}> <View style={styles.headerButtons}>
{isEditing ? ( {isEditing ? (
<> <>
{!isCreateMode && (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
setIsEditing(false); setIsEditing(false);
reset(); // reset to previous values reset(); // reset to previous values
}} }}
style={[styles.saveButton, { backgroundColor: "#6c757d" }]} style={[
styles.saveButton,
{ backgroundColor: "#6c757d" },
]}
> >
<Text style={styles.saveButtonText}>Hủy</Text> <Text style={styles.saveButtonText}>Hủy</Text>
</TouchableOpacity> </TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
style={styles.saveButton} style={styles.saveButton}

View File

@@ -30,7 +30,7 @@ export const InfoSection: React.FC<InfoSectionProps> = ({
{ {
label: "Thời gian kết thúc", label: "Thời gian kết thúc",
value: value:
fishingLog.end_at !== "0001-01-01T00:00:00Z" fishingLog.end_at.toString() !== "0001-01-01T00:00:00Z"
? new Date(fishingLog.end_at).toLocaleString() ? new Date(fishingLog.end_at).toLocaleString()
: "-", : "-",
}, },

View File

@@ -1,4 +1,4 @@
import { TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { removeStorageItem } from "@/utils/storage"; import { removeStorageItem } from "@/utils/storage";
import { Router } from "expo-router"; import { Router } from "expo-router";
@@ -17,7 +17,12 @@ export const setRouterInstance = (router: Router) => {
export const handle401 = () => { export const handle401 = () => {
if (routerInstance) { if (routerInstance) {
removeStorageItem(TOKEN); removeStorageItem(TOKEN);
(routerInstance as any).replace("/auth/login"); removeStorageItem(DOMAIN);
// Cancel all pending requests to prevent further API calls
if (typeof window !== "undefined" && (window as any).axiosAbortController) {
(window as any).axiosAbortController.abort();
}
routerInstance.navigate("/auth/login");
} else { } else {
console.warn("Router instance not set, cannot redirect to login"); console.warn("Router instance not set, cannot redirect to login");
} }

View File

@@ -1,8 +1,8 @@
import { TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { showErrorToast } from "@/services/toast_service";
import { getStorageItem } from "@/utils/storage"; import { getStorageItem } from "@/utils/storage";
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { handle401 } from "./auth"; import { handle401 } from "./auth";
import { showToastError } from "./toast";
const codeMessage = { const codeMessage = {
200: "The server successfully returned the requested data。", 200: "The server successfully returned the requested data。",
@@ -38,9 +38,15 @@ api.interceptors.request.use(
async (config) => { async (config) => {
// Thêm auth token nếu có // Thêm auth token nếu có
const token = await getStorageItem(TOKEN); const token = await getStorageItem(TOKEN);
const domain = await getStorageItem(DOMAIN);
if (domain) {
config.baseURL = `http://${domain}`;
}
if (token) { if (token) {
config.headers.Authorization = `${token}`; config.headers.Authorization = `${token}`;
} }
// console.log("Domain Request: ", config.baseURL);
return config; return config;
}, },
(error) => { (error) => {
@@ -57,7 +63,9 @@ api.interceptors.response.use(
if (!error.response) { if (!error.response) {
const networkErrorMsg = const networkErrorMsg =
error.message || "Network error - please check connection"; error.message || "Network error - please check connection";
showToastError("Lỗi kết nối", networkErrorMsg); showErrorToast("Lỗi kết nối");
console.error("Response Network Error: ", networkErrorMsg);
return Promise.reject(error); return Promise.reject(error);
} }
@@ -70,8 +78,7 @@ api.interceptors.response.use(
statusText || statusText ||
"Unknown error"; "Unknown error";
showToastError(`Lỗi ${status}`, errMsg); showErrorToast(`Lỗi ${status}: ${errMsg}`);
if (status === 401) { if (status === 401) {
handle401(); handle401();
} }

View File

@@ -1,5 +1,5 @@
export const TOKEN = "token"; export const TOKEN = "token";
export const BASE_URL = "https://sgw-device.gms.vn"; export const DOMAIN = "domain";
export const MAP_TRACKPOINTS_ID = "ship-trackpoints"; export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
export const MAP_POLYLINE_BAN = "ban-polyline"; export const MAP_POLYLINE_BAN = "ban-polyline";
export const MAP_POLYGON_BAN = "ban-polygon"; export const MAP_POLYGON_BAN = "ban-polygon";