From aabd1109b296ca8c5eb84921af5b67e6dd09c2c7 Mon Sep 17 00:00:00 2001 From: Tran Anh Tuan Date: Thu, 6 Nov 2025 17:30:04 +0700 Subject: [PATCH] =?UTF-8?q?th=C3=AAm=20toast,=20th=C3=AAm=20logic=20cho=20?= =?UTF-8?q?ph=E1=BA=A7n=20ButtonCreateNewHaulOrTrip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 7 +- components/ButtonCreateNewHaulOrTrip.tsx | 144 ++++++++++-- components/IconButton.tsx | 159 +++++++++++++ components/map/GPSInfoPanel.tsx | 27 ++- components/tripInfo/NetListTable.tsx | 5 +- .../modal/CreateOrUpdateHaulModal.tsx | 29 +++ .../modal/NetDetailModal/NetDetailModal.tsx | 13 +- .../NetDetailModal/components/InfoSection.tsx | 5 +- .../style/NetDetailModal.styles.ts | 177 +++++++++++++++ config/toast.ts | 79 ------- config/toast.tsx | 214 ++++++++++++++++++ controller/DeviceController.ts | 2 +- controller/TripController.ts | 14 +- controller/typings.d.ts | 14 +- services/toast_service.tsx | 29 +++ 15 files changed, 799 insertions(+), 119 deletions(-) create mode 100644 components/IconButton.tsx create mode 100644 components/tripInfo/modal/CreateOrUpdateHaulModal.tsx create mode 100644 components/tripInfo/modal/NetDetailModal/style/NetDetailModal.styles.ts delete mode 100644 config/toast.ts create mode 100644 config/toast.tsx create mode 100644 services/toast_service.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 1b519ba..84af030 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,14 +7,15 @@ import { Stack, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useEffect } from "react"; import "react-native-reanimated"; -import Toast from "react-native-toast-message"; +// import Toast from "react-native-toast-message"; import { GluestackUIProvider } from "@/components/ui/gluestack-ui-provider/gluestack-ui-provider"; +import { toastConfig } from "@/config"; import { setRouterInstance } from "@/config/auth"; import "@/global.css"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import Toast from "react-native-toast-message"; import "../global.css"; - export default function RootLayout() { const colorScheme = useColorScheme(); const router = useRouter(); @@ -52,7 +53,7 @@ export default function RootLayout() { /> - + ); diff --git a/components/ButtonCreateNewHaulOrTrip.tsx b/components/ButtonCreateNewHaulOrTrip.tsx index 13c2e8a..2873882 100644 --- a/components/ButtonCreateNewHaulOrTrip.tsx +++ b/components/ButtonCreateNewHaulOrTrip.tsx @@ -1,17 +1,40 @@ +import { queryGpsData } from "@/controller/DeviceController"; +import { + queryStartNewHaul, + queryUpdateTripState, +} from "@/controller/TripController"; +import { + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@/services/toast_service"; +import { useTrip } from "@/state/use-trip"; import { AntDesign } from "@expo/vector-icons"; -import React, { useState } from "react"; -import { Alert, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import React, { useEffect, useState } from "react"; +import { Alert, StyleSheet, View } from "react-native"; +import IconButton from "./IconButton"; +import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal"; interface StartButtonProps { - title?: string; + gpsData?: Model.GPSResponse; onPress?: () => void; } const ButtonCreateNewHaulOrTrip: React.FC = ({ - title = "Bắt đầu mẻ lưới", + gpsData, onPress, }) => { const [isStarted, setIsStarted] = useState(false); + const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false); + + const { trip, getTrip } = useTrip(); + useEffect(() => { + getTrip(); + }, [trip === null]); + + const checkHaulFinished = () => { + return trip?.fishing_logs?.some((h) => h.status === 0); + }; const handlePress = () => { if (isStarted) { @@ -53,24 +76,103 @@ const ButtonCreateNewHaulOrTrip: React.FC = ({ } }; + const handleStartTrip = async (state: number, note?: string) => { + if (trip?.trip_status !== 2) { + showWarningToast("Chuyến đi đã được bắt đầu hoặc hoàn thành."); + return; + } + try { + const resp = await queryUpdateTripState({ + status: state, + note: note || "", + }); + if (resp.status === 200) { + showSuccessToast("Bắt đầu chuyến đi thành công!"); + getTrip(); + } + } catch (error) { + console.error("Error stating trip :", error); + showErrorToast(""); + } + }; + + const createNewHaul = async () => { + if (trip?.fishing_logs?.some((f) => f.status === 0)) { + showWarningToast( + "Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới" + ); + return; + } + if (!gpsData) { + const response = await queryGpsData(); + gpsData = response.data; + } + try { + const body: Model.NewFishingLogRequest = { + trip_id: trip?.id || "", + start_at: new Date(), + start_lat: gpsData.lat, + start_lon: gpsData.lon, + weather_description: "Nắng đẹp", + }; + + const resp = await queryStartNewHaul(body); + if (resp.status === 200) { + showSuccessToast("Bắt đầu mẻ lưới mới thành công!"); + getTrip(); + } else { + showErrorToast("Tạo mẻ lưới mới thất bại!"); + } + } catch (error) { + console.log(error); + // showErrorToast("Tạo mẻ lưới mới thất bại!"); + } + }; + + // Không render gì nếu trip đã hoàn thành hoặc bị hủy + if (trip?.trip_status === 4 || trip?.trip_status === 5) { + return null; + } + return ( - - - - - {isStarted ? "Kết thúc mẻ lưới" : title} - - - + + {trip?.trip_status === 2 ? ( + } + type="primary" + style={{ backgroundColor: "green", borderRadius: 10 }} + onPress={async () => handleStartTrip(3)} + > + Bắt đầu chuyến đi + + ) : checkHaulFinished() ? ( + } + type="primary" + style={{ borderRadius: 10 }} + onPress={() => setIsFinishHaulModalOpen(true)} + > + Kết thúc mẻ lưới + + ) : ( + } + type="primary" + style={{ borderRadius: 10 }} + onPress={async () => { + createNewHaul(); + }} + > + Bắt đầu mẻ lưới + + )} + + ); }; diff --git a/components/IconButton.tsx b/components/IconButton.tsx new file mode 100644 index 0000000..d3a6d05 --- /dev/null +++ b/components/IconButton.tsx @@ -0,0 +1,159 @@ +import React from "react"; +import { + ActivityIndicator, + GestureResponderEvent, + StyleProp, + StyleSheet, + Text, + TouchableOpacity, + View, + ViewStyle, +} from "react-native"; + +type ButtonType = "primary" | "default" | "dashed" | "text" | "link" | "danger"; +type ButtonShape = "default" | "circle" | "round"; +type ButtonSize = "small" | "middle" | "large"; + +export interface IconButtonProps { + type?: ButtonType; + shape?: ButtonShape; + size?: ButtonSize; + icon?: React.ReactNode; // render an icon component, e.g. + loading?: boolean; + disabled?: boolean; + onPress?: (e?: GestureResponderEvent) => void; + children?: React.ReactNode; // label text + style?: StyleProp; + block?: boolean; // full width + activeOpacity?: number; +} + +/** + * IconButton + * A lightweight Button component inspired by Ant Design Button API, tuned for React Native. + * Accepts an `icon` prop as a React node for maximum flexibility. + */ +const IconButton: React.FC = ({ + type = "default", + shape = "default", + size = "middle", + icon, + loading = false, + disabled = false, + onPress, + children, + style, + block = false, + activeOpacity = 0.8, +}) => { + const sizeMap = { + small: { height: 32, fontSize: 14, paddingHorizontal: 10 }, + middle: { height: 40, fontSize: 16, paddingHorizontal: 14 }, + large: { height: 48, fontSize: 18, paddingHorizontal: 18 }, + } as const; + + const colors: Record< + ButtonType, + { backgroundColor?: string; textColor: string; borderColor?: string } + > = { + primary: { backgroundColor: "#4ecdc4", textColor: "#fff" }, + default: { + backgroundColor: "#f2f2f2", + textColor: "#111", + borderColor: "#e6e6e6", + }, + dashed: { + backgroundColor: "#fff", + textColor: "#111", + borderColor: "#d9d9d9", + }, + text: { backgroundColor: "transparent", textColor: "#111" }, + link: { backgroundColor: "transparent", textColor: "#4ecdc4" }, + danger: { backgroundColor: "#e74c3c", textColor: "#fff" }, + }; + + const sz = sizeMap[size]; + const color = colors[type]; + + const isCircle = shape === "circle"; + const isRound = shape === "round"; + + const handlePress = (e: GestureResponderEvent) => { + if (disabled || loading) return; + onPress?.(e); + }; + + return ( + + + {loading ? ( + + ) : icon ? ( + {icon} + ) : null} + + {children ? ( + + {children} + + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + button: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + borderColor: "transparent", + }, + content: { + flexDirection: "row", + alignItems: "center", + }, + iconContainer: { + alignItems: "center", + justifyContent: "center", + }, + text: { + fontWeight: "600", + }, +}); + +export default IconButton; diff --git a/components/map/GPSInfoPanel.tsx b/components/map/GPSInfoPanel.tsx index f56f317..a6a53bb 100644 --- a/components/map/GPSInfoPanel.tsx +++ b/components/map/GPSInfoPanel.tsx @@ -2,10 +2,10 @@ import { convertToDMS, kmhToKnot } from "@/utils/geom"; import { MaterialIcons } from "@expo/vector-icons"; import { useEffect, useRef, useState } from "react"; import { Animated, TouchableOpacity, View } from "react-native"; +import ButtonCreateNewHaulOrTrip from "../ButtonCreateNewHaulOrTrip"; import { Description } from "./Description"; - type GPSInfoPanelProps = { - gpsData: Model.GPSResonse | undefined; + gpsData: Model.GPSResponse | undefined; }; const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => { @@ -43,13 +43,28 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => { position: "absolute", bottom: blockBottom, left: 5, - width: 48, - height: 48, - backgroundColor: "blue", + // width: 48, + // height: 48, + // backgroundColor: "blue", borderRadius: 4, zIndex: 30, }} - /> + > + + {/* { + // showInfoToast("oad"); + showWarningToast("This is a warning toast!"); + }} + className="absolute top-2 right-2 z-10 bg-white rounded-full p-1" + > + + */} + { {/* Modal chi tiết */} setModalVisible(false)} + onClose={() => { + console.log("OnCLose"); + setModalVisible(false); + }} netData={selectedNet} /> diff --git a/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx new file mode 100644 index 0000000..2e32d7d --- /dev/null +++ b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Modal, Text } from "react-native"; + +interface CreateOrUpdateHaulModalProps { + isVisible: boolean; + onClose: () => void; + haulData?: Model.FishingLog | null; +} + +const CreateOrUpdateHaulModal: React.FC = ({ + isVisible, + onClose, + haulData, +}) => { + const [isCreateMode, setIsCreateMode] = React.useState(!haulData); + + return ( + + {isCreateMode ? "Create Haul" : "Update Haul"} + + ); +}; + +export default CreateOrUpdateHaulModal; diff --git a/components/tripInfo/modal/NetDetailModal/NetDetailModal.tsx b/components/tripInfo/modal/NetDetailModal/NetDetailModal.tsx index b5349d3..ceaf2f0 100644 --- a/components/tripInfo/modal/NetDetailModal/NetDetailModal.tsx +++ b/components/tripInfo/modal/NetDetailModal/NetDetailModal.tsx @@ -91,9 +91,9 @@ const NetDetailModal: React.FC = ({ } }, [visible]); - if (!netData) return null; + // if (!netData) return null; - const isCompleted = netData.trangThai === "Đã hoàn thành"; + const isCompleted = netData?.trangThai === "Đã hoàn thành"; // Danh sách tên cá có sẵn const fishNameOptions = [ @@ -210,7 +210,7 @@ const NetDetailModal: React.FC = ({ const handleCancel = () => { setIsEditing(false); - setEditableCatchList(netData.catchList || []); + setEditableCatchList(netData?.catchList || []); }; const handleToggleExpanded = (index: number) => { @@ -343,7 +343,10 @@ const NetDetailModal: React.FC = ({ {/* Content */} {/* Thông tin chung */} - + {/* Danh sách cá bắt được */} @@ -372,7 +375,7 @@ const NetDetailModal: React.FC = ({ /> {/* Ghi chú */} - + diff --git a/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx b/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx index 43da266..d53430d 100644 --- a/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx +++ b/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx @@ -17,7 +17,7 @@ interface NetDetail { } interface InfoSectionProps { - netData: NetDetail; + netData?: NetDetail; isCompleted: boolean; } @@ -25,6 +25,9 @@ export const InfoSection: React.FC = ({ netData, isCompleted, }) => { + if (!netData) { + return null; + } const infoItems = [ { label: "Số thứ tự", value: netData.stt }, { diff --git a/components/tripInfo/modal/NetDetailModal/style/NetDetailModal.styles.ts b/components/tripInfo/modal/NetDetailModal/style/NetDetailModal.styles.ts new file mode 100644 index 0000000..5089336 --- /dev/null +++ b/components/tripInfo/modal/NetDetailModal/style/NetDetailModal.styles.ts @@ -0,0 +1,177 @@ +import { StyleSheet } from "react-native"; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#fff", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 8, + backgroundColor: "#f8f9fa", + borderBottomWidth: 1, + borderBottomColor: "#e9ecef", + }, + title: { + fontSize: 18, + fontWeight: "bold", + color: "#333", + }, + closeButton: { + padding: 8, + }, + closeButtonText: { + fontSize: 16, + color: "#007bff", + }, + content: { + flex: 1, + padding: 16, + }, + fieldGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: "600", + color: "#333", + marginBottom: 4, + }, + input: { + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 4, + padding: 8, + fontSize: 16, + backgroundColor: "#fff", + }, + infoValue: { + fontSize: 16, + color: "#555", + paddingVertical: 8, + }, + rowGroup: { + flexDirection: "row", + justifyContent: "space-between", + }, + fishNameDropdown: { + // Custom styles if needed + }, + optionsStatusFishList: { + // Custom styles if needed + }, + optionsList: { + maxHeight: 150, + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 4, + backgroundColor: "#fff", + position: "absolute", + top: 40, + left: 0, + right: 0, + zIndex: 1000, + }, + selectButton: { + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 4, + padding: 8, + backgroundColor: "#fff", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + selectButtonText: { + fontSize: 16, + color: "#333", + }, + optionItem: { + padding: 10, + borderBottomWidth: 1, + borderBottomColor: "#eee", + }, + optionText: { + fontSize: 16, + color: "#333", + }, + card: { + borderWidth: 1, + borderColor: "#ddd", + borderRadius: 8, + padding: 12, + marginBottom: 12, + backgroundColor: "#f9f9f9", + }, + removeButton: { + backgroundColor: "#dc3545", + padding: 8, + borderRadius: 4, + alignSelf: "flex-end", + marginTop: 8, + }, + removeButtonText: { + color: "#fff", + fontSize: 14, + }, + errorText: { + color: "#dc3545", + fontSize: 12, + marginTop: 4, + }, + buttonGroup: { + flexDirection: "row", + justifyContent: "space-around", + marginTop: 16, + }, + editButton: { + backgroundColor: "#007bff", + padding: 10, + borderRadius: 4, + }, + editButtonText: { + color: "#fff", + fontSize: 16, + }, + addButton: { + backgroundColor: "#28a745", + padding: 10, + borderRadius: 4, + }, + addButtonText: { + color: "#fff", + fontSize: 16, + }, + saveButton: { + backgroundColor: "#007bff", + padding: 10, + borderRadius: 4, + }, + saveButtonText: { + color: "#fff", + fontSize: 16, + }, + cancelButton: { + backgroundColor: "#6c757d", + padding: 10, + borderRadius: 4, + }, + cancelButtonText: { + color: "#fff", + fontSize: 16, + }, + addFishButton: { + backgroundColor: "#17a2b8", + padding: 10, + borderRadius: 4, + marginBottom: 16, + }, + addFishButtonText: { + color: "#fff", + fontSize: 16, + }, +}); diff --git a/config/toast.ts b/config/toast.ts deleted file mode 100644 index c884608..0000000 --- a/config/toast.ts +++ /dev/null @@ -1,79 +0,0 @@ -import Toast from "react-native-toast-message"; - -export enum ToastType { - SUCCESS = "success", - ERROR = "error", - WARNING = "error", // react-native-toast-message không có 'warning', dùng 'error' - INFO = "info", -} - -/** - * Success toast - */ -export const showToastSuccess = (message: string, title?: string): void => { - Toast.show({ - type: "success", - text1: title || "Success", - text2: message, - }); -}; - -/** - * Error toast - */ -export const showToastError = (message: string, title?: string): void => { - Toast.show({ - type: ToastType.ERROR, - text1: title || ToastType.ERROR, - text2: message, - }); -}; - -/** - * Info toast - */ -export const showToastInfo = (message: string, title?: string): void => { - Toast.show({ - type: ToastType.INFO, - text1: title || ToastType.INFO, - text2: message, - }); -}; - -/** - * Warning toast - */ -export const showToastWarning = (message: string, title?: string): void => { - Toast.show({ - type: ToastType.WARNING, - text1: title || ToastType.WARNING, - text2: message, - }); -}; - -/** - * Default toast - */ -export const showToastDefault = (message: string, title?: string): void => { - Toast.show({ - type: ToastType.INFO, - text1: title || ToastType.INFO, - text2: message, - }); -}; - -/** - * Custom toast với type tùy chọn - */ -export const show = ( - message: string, - type: ToastType, - title?: string -): void => { - const titleText = title || type.charAt(0).toUpperCase() + type.slice(1); - Toast.show({ - type, - text1: titleText, - text2: message, - }); -}; diff --git a/config/toast.tsx b/config/toast.tsx new file mode 100644 index 0000000..0a5f098 --- /dev/null +++ b/config/toast.tsx @@ -0,0 +1,214 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import { View } from "react-native"; +import { + BaseToast, + BaseToastProps, + SuccessToast, +} from "react-native-toast-message"; + +export const Colors: any = { + light: { + text: "#000", + back: "#ffffff", + }, + dark: { + text: "#ffffff", + back: "#2B2D2E", + }, + default: "#3498db", + info: "#3498db", + success: "#07bc0c", + warn: { + background: "#ffffff", + text: "black", + iconColor: "#f1c40f", + }, + error: { + background: "#ffffff", + text: "black", + iconColor: "#e74c3c", + }, + textDefault: "#4c4c4c", + textDark: "black", +}; + +export const toastConfig = { + success: (props: BaseToastProps) => ( + ( + + + + )} + /> + ), + default: (props: BaseToastProps) => ( + ( + + + + )} + /> + ), + info: (props: BaseToastProps) => ( + ( + + + + )} + /> + ), + warn: (props: BaseToastProps) => ( + ( + + + + )} + /> + ), + error: (props: BaseToastProps) => ( + ( + + + + )} + /> + ), +}; diff --git a/controller/DeviceController.ts b/controller/DeviceController.ts index 09aaaae..2df127a 100644 --- a/controller/DeviceController.ts +++ b/controller/DeviceController.ts @@ -9,7 +9,7 @@ import { import { transformEntityResponse } from "@/utils/tranform"; export async function queryGpsData() { - return api.get(API_GET_GPS); + return api.get(API_GET_GPS); } export async function queryAlarm() { diff --git a/controller/TripController.ts b/controller/TripController.ts index 9bc7acd..a27397c 100644 --- a/controller/TripController.ts +++ b/controller/TripController.ts @@ -1,6 +1,18 @@ import { api } from "@/config"; -import { API_GET_TRIP } from "@/constants"; +import { + API_GET_TRIP, + API_HAUL_HANDLE, + API_UPDATE_TRIP_STATUS, +} from "@/constants"; export async function queryTrip() { return api.get(API_GET_TRIP); } + +export async function queryUpdateTripState(body: Model.TripUpdateStateRequest) { + return api.put(API_UPDATE_TRIP_STATUS, body); +} + +export async function queryStartNewHaul(body: Model.NewFishingLogRequest) { + return api.put(API_HAUL_HANDLE, body); +} diff --git a/controller/typings.d.ts b/controller/typings.d.ts index e92b6c0..b9d00e7 100644 --- a/controller/typings.d.ts +++ b/controller/typings.d.ts @@ -7,7 +7,7 @@ declare namespace Model { token?: string; } - interface GPSResonse { + interface GPSResponse { lat: number; lon: number; s: number; @@ -174,4 +174,16 @@ declare namespace Model { fish_condition?: string; gear_usage?: string; } + interface NewFishingLogRequest { + trip_id: string; + start_at: Date; // ISO datetime + start_lat: number; + start_lon: number; + weather_description: string; + } + + interface TripUpdateStateRequest { + status: number; + note?: string; + } } diff --git a/services/toast_service.tsx b/services/toast_service.tsx new file mode 100644 index 0000000..3187d19 --- /dev/null +++ b/services/toast_service.tsx @@ -0,0 +1,29 @@ +import Toast from "react-native-toast-message"; + +export function showInfoToast(message: string) { + Toast.show({ + type: "info", + text1: message, + }); +} + +export function showSuccessToast(message: string) { + Toast.show({ + type: "success", + text1: message, + }); +} + +export function showErrorToast(message: string) { + Toast.show({ + type: "error", + text1: message, + }); +} + +export function showWarningToast(message: string) { + Toast.show({ + type: "warn", + text1: message, + }); +}