add en/vi language

This commit is contained in:
Tran Anh Tuan
2025-11-15 16:58:07 +07:00
parent 1a534eccb0
commit e725819c01
31 changed files with 1843 additions and 232 deletions

View File

@@ -1,3 +1,4 @@
import { useI18n } from "@/hooks/use-i18n";
import React from "react";
import { StyleSheet, Text, TouchableOpacity } from "react-native";
@@ -7,16 +8,18 @@ interface ButtonCancelTripProps {
}
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
title = "Hủy chuyến đi",
title,
onPress,
}) => {
const { t } = useI18n();
const displayTitle = title || t("trip.buttonCancelTrip.title");
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.8}
>
<Text style={styles.text}>{title}</Text>
<Text style={styles.text}>{displayTitle}</Text>
</TouchableOpacity>
);
};

View File

@@ -3,6 +3,7 @@ import {
queryStartNewHaul,
queryUpdateTripState,
} from "@/controller/TripController";
import { useI18n } from "@/hooks/use-i18n";
import {
showErrorToast,
showSuccessToast,
@@ -32,6 +33,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
}) => {
const [isStarted, setIsStarted] = useState(false);
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
const { t } = useI18n();
const { trip, getTrip } = useTrip();
useEffect(() => {
@@ -44,34 +46,30 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
const handlePress = () => {
if (isStarted) {
Alert.alert(
"Kết thúc mẻ lưới",
"Bạn có chắc chắn muốn kết thúc mẻ lưới này?",
[
{
text: "Hủy",
style: "cancel",
},
{
text: "Kết thúc",
onPress: () => {
setIsStarted(false);
Alert.alert("Thành công", "Đã kết thúc mẻ lưới!");
},
},
]
);
} else {
Alert.alert("Bắt đầu mẻ lưới", "Bạn có muốn bắt đầu mẻ lưới mới?", [
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
{
text: "Hủy",
text: t("trip.cancelButton"),
style: "cancel",
},
{
text: "Bắt đầu",
text: t("trip.endButton"),
onPress: () => {
setIsStarted(false);
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
},
},
]);
} else {
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
{
text: t("trip.cancelButton"),
style: "cancel",
},
{
text: t("trip.startButton"),
onPress: () => {
setIsStarted(true);
Alert.alert("Thành công", "Đã bắt đầu mẻ lưới mới!");
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
},
},
]);
@@ -84,7 +82,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
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.");
showWarningToast(t("trip.alreadyStarted"));
return;
}
try {
@@ -93,7 +91,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
note: note || "",
});
if (resp.status === 200) {
showSuccessToast("Bắt đầu chuyến đi thành công!");
showSuccessToast(t("trip.startTripSuccess"));
await getTrip();
}
} catch (error) {
@@ -104,9 +102,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
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"
);
showWarningToast(t("trip.finishCurrentHaul"));
return;
}
if (!gpsData) {
@@ -119,19 +115,19 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
start_at: new Date(),
start_lat: gpsData.lat,
start_lon: gpsData.lon,
weather_description: "Nắng đẹp",
weather_description: t("trip.weatherDescription"),
};
const resp = await queryStartNewHaul(body);
if (resp.status === 200) {
showSuccessToast("Bắt đầu mẻ lưới mới thành công!");
showSuccessToast(t("trip.startHaulSuccess"));
await getTrip();
} else {
showErrorToast("Tạo mẻ lưới mới thất bại!");
showErrorToast(t("trip.createHaulFailed"));
}
} catch (error) {
console.log(error);
// showErrorToast("Tạo mẻ lưới mới thất bại!");
// showErrorToast(t("trip.createHaulFailed"));
}
};
@@ -149,7 +145,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
style={{ backgroundColor: "green", borderRadius: 10 }}
onPress={async () => handleStartTrip(3)}
>
Bắt đu chuyến đi
{t("trip.startTrip")}
</IconButton>
) : checkHaulFinished() ? (
<IconButton
@@ -158,7 +154,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
style={{ borderRadius: 10 }}
onPress={() => setIsFinishHaulModalOpen(true)}
>
Kết thúc mẻ lưới
{t("trip.endHaul")}
</IconButton>
) : (
<IconButton
@@ -169,7 +165,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
createNewHaul();
}}
>
Bắt đu mẻ lưới
{t("trip.startHaul")}
</IconButton>
)}
<CreateOrUpdateHaulModal

View File

@@ -1,3 +1,4 @@
import { useI18n } from "@/hooks/use-i18n";
import React from "react";
import { StyleSheet, Text, TouchableOpacity } from "react-native";
@@ -6,17 +7,16 @@ interface ButtonEndTripProps {
onPress?: () => void;
}
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({
title = "Kết thúc",
onPress,
}) => {
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
const { t } = useI18n();
const displayTitle = title || t("trip.buttonEndTrip.title");
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.85}
>
<Text style={styles.text}>{title}</Text>
<Text style={styles.text}>{displayTitle}</Text>
</TouchableOpacity>
);
};

View File

@@ -1,3 +1,4 @@
import { useI18n } from "@/hooks/use-i18n";
import { convertToDMS, kmhToKnot } from "@/utils/geom";
import { MaterialIcons } from "@expo/vector-icons";
import { useEffect, useRef, useState } from "react";
@@ -13,7 +14,7 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
const [panelHeight, setPanelHeight] = useState(0);
const translateY = useRef(new Animated.Value(0)).current;
const blockBottom = useRef(new Animated.Value(0)).current;
const { t } = useI18n();
useEffect(() => {
Animated.timing(translateY, {
toValue: isExpanded ? 0 : 200, // Dịch chuyển xuống 200px khi thu gọn
@@ -88,13 +89,13 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
<View className="flex-row justify-between">
<View className="flex-1">
<Description
title="Kinh độ"
title={t("home.latitude")}
description={convertToDMS(gpsData?.lat ?? 0, true)}
/>
</View>
<View className="flex-1">
<Description
title="Vĩ độ"
title={t("home.longitude")}
description={convertToDMS(gpsData?.lon ?? 0, false)}
/>
</View>
@@ -102,12 +103,15 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
<View className="flex-row justify-between">
<View className="flex-1">
<Description
title="Tốc độ"
title={t("home.speed")}
description={`${kmhToKnot(gpsData?.s ?? 0).toString()} knot`}
/>
</View>
<View className="flex-1">
<Description title="Hướng" description={`${gpsData?.h ?? 0}°`} />
<Description
title={t("home.heading")}
description={`${gpsData?.h ?? 0}°`}
/>
</View>
</View>
</Animated.View>

View File

@@ -3,6 +3,7 @@ import {
queryGetSos,
querySendSosMessage,
} from "@/controller/DeviceController";
import { useI18n } from "@/hooks/use-i18n";
import { showErrorToast } from "@/services/toast_service";
import { sosMessage } from "@/utils/sosUtils";
import { MaterialIcons } from "@expo/vector-icons";
@@ -35,7 +36,7 @@ const SosButton = () => {
const [customMessage, setCustomMessage] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const { t } = useI18n();
const sosOptions = [
...sosMessage.map((msg) => ({ ma: msg.ma, moTa: msg.moTa })),
{ ma: 999, moTa: "Khác" },
@@ -60,7 +61,7 @@ const SosButton = () => {
// Không cần validate sosMessage vì luôn có default value (11)
if (selectedSosMessage === 999 && customMessage.trim() === "") {
newErrors.customMessage = "Vui lòng nhập trạng thái";
newErrors.customMessage = t("home.sos.statusRequired");
}
setErrors(newErrors);
@@ -108,7 +109,7 @@ const SosButton = () => {
}
} catch (error) {
console.error("Error when send sos: ", error);
showErrorToast("Không thể gửi tín hiệu SOS");
showErrorToast(t("home.sos.sendError"));
}
};
@@ -122,7 +123,7 @@ const SosButton = () => {
>
<MaterialIcons name="warning" size={15} color="white" />
<ButtonText className="text-center">
{sosData?.active ? "Đang trong trạng thái khẩn cấp" : "Khẩn cấp"}
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
</ButtonText>
{/* <ButtonSpinner /> */}
{/* <ButtonIcon /> */}
@@ -142,14 +143,14 @@ const SosButton = () => {
<Text
style={{ fontSize: 18, fontWeight: "bold", textAlign: "center" }}
>
Thông báo khẩn cấp
{t("home.sos.title")}
</Text>
</ModalHeader>
<ModalBody className="mb-4">
<ScrollView style={{ maxHeight: 400 }}>
{/* Dropdown Nội dung SOS */}
<View style={styles.formGroup}>
<Text style={styles.label}>Nội dung:</Text>
<Text style={styles.label}>{t("home.sos.content")}</Text>
<TouchableOpacity
style={[
styles.dropdownButton,
@@ -165,8 +166,8 @@ const SosButton = () => {
>
{selectedSosMessage !== null
? sosOptions.find((opt) => opt.ma === selectedSosMessage)
?.moTa || "Chọn lý do"
: "Chọn lý do"}
?.moTa || t("home.sos.selectReason")
: t("home.sos.selectReason")}
</Text>
<MaterialIcons
name={showDropdown ? "expand-less" : "expand-more"}
@@ -182,13 +183,13 @@ const SosButton = () => {
{/* Input Custom Message nếu chọn "Khác" */}
{selectedSosMessage === 999 && (
<View style={styles.formGroup}>
<Text style={styles.label}>Nhập trạng thái</Text>
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
<TextInput
style={[
styles.input,
errors.customMessage ? styles.errorInput : {},
]}
placeholder="Mô tả trạng thái khẩn cấp"
placeholder={t("home.sos.enterStatus")}
placeholderTextColor="#999"
value={customMessage}
onChangeText={(text) => {
@@ -217,7 +218,7 @@ const SosButton = () => {
// className="w-1/3"
action="negative"
>
<ButtonText>Xác nhận</ButtonText>
<ButtonText>{t("home.sos.confirm")}</ButtonText>
</Button>
<Button
onPress={() => {
@@ -229,7 +230,7 @@ const SosButton = () => {
// className="w-1/3"
action="secondary"
>
<ButtonText>Hủy</ButtonText>
<ButtonText>{t("home.sos.cancel")}</ButtonText>
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -0,0 +1,307 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Image,
ImageSourcePropType,
Pressable,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from "react-native";
const AnimatedImage = Animated.createAnimatedComponent(Image);
const SIZE_PRESETS = {
sm: { width: 64, height: 32 },
md: { width: 80, height: 40 },
lg: { width: 96, height: 48 },
} as const;
type SwitchSize = keyof typeof SIZE_PRESETS;
const DEFAULT_TOGGLE_DURATION = 400;
const DEFAULT_OFF_IMAGE =
"https://images.vexels.com/media/users/3/163966/isolated/preview/6ecbb5ec8c121c0699c9b9179d6b24aa-england-flag-language-icon-circle.png";
const DEFAULT_ON_IMAGE =
"https://cdn-icons-png.flaticon.com/512/197/197473.png";
const DEFAULT_INACTIVE_BG = "#D3DAD9";
const DEFAULT_ACTIVE_BG = "#C2E2FA";
const PRESSED_SCALE = 0.96;
const PRESS_FEEDBACK_DURATION = 120;
type RotateSwitchProps = {
size?: SwitchSize;
onImage?: ImageSourcePropType | string;
offImage?: ImageSourcePropType | string;
initialValue?: boolean;
duration?: number;
activeBackgroundColor?: string;
inactiveBackgroundColor?: string;
style?: StyleProp<ViewStyle>;
onChange?: (value: boolean) => void;
};
const toImageSource = (
input: ImageSourcePropType | string | undefined,
fallbackUri: string
): ImageSourcePropType => {
if (typeof input === "string") {
return { uri: input };
}
if (input) {
return input;
}
return { uri: fallbackUri };
};
const RotateSwitch = ({
size = "md",
onImage,
offImage,
duration,
activeBackgroundColor = DEFAULT_ACTIVE_BG,
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
initialValue = false,
style,
onChange,
}: RotateSwitchProps) => {
const { width: containerWidth, height: containerHeight } =
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
const knobSize = containerHeight;
const knobTravel = containerWidth - knobSize;
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
const resolvedOffImage = useMemo(
() => toImageSource(offImage, DEFAULT_OFF_IMAGE),
[offImage]
);
const resolvedOnImage = useMemo(
() => toImageSource(onImage, DEFAULT_ON_IMAGE),
[onImage]
);
const [isOn, setIsOn] = useState(initialValue);
const [bgOn, setBgOn] = useState(initialValue);
const [displaySource, setDisplaySource] = useState<ImageSourcePropType>(
initialValue ? resolvedOnImage : resolvedOffImage
);
const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current;
const pressScale = useRef(new Animated.Value(1)).current;
const listenerIdRef = useRef<string | number | null>(null);
useEffect(() => {
setDisplaySource(bgOn ? resolvedOnImage : resolvedOffImage);
}, [bgOn, resolvedOffImage, resolvedOnImage]);
const removeProgressListener = () => {
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
};
const attachHalfwaySwapListener = (next: boolean) => {
removeProgressListener();
let swapped = false;
listenerIdRef.current = progress.addListener(({ value }) => {
if (swapped) return;
const crossedHalfway = next ? value >= 0.5 : value <= 0.5;
if (!crossedHalfway) return;
swapped = true;
setBgOn(next);
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
removeProgressListener();
});
};
// Clean up listener on unmount
useEffect(() => {
return () => {
removeProgressListener();
};
}, []);
// Keep internal state in sync when `initialValue` prop changes.
// Users may pass a changing `initialValue` (like from parent state) and
// expect the switch to reflect that. Animate `progress` toward the
// corresponding value and update images/background when done.
useEffect(() => {
// If no change, do nothing
if (initialValue === isOn) return;
const next = initialValue;
const targetValue = next ? 1 : 0;
progress.stopAnimation();
removeProgressListener();
if (animationDuration <= 0) {
progress.setValue(targetValue);
setIsOn(next);
setBgOn(next);
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
return;
}
// Update isOn immediately so accessibilityState etc. reflect change.
setIsOn(next);
attachHalfwaySwapListener(next);
Animated.timing(progress, {
toValue: targetValue,
duration: animationDuration,
useNativeDriver: true,
}).start(() => {
// Ensure final state reflects the target in case animation skips halfway listener.
setBgOn(next);
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
});
}, [
initialValue,
isOn,
animationDuration,
progress,
resolvedOffImage,
resolvedOnImage,
]);
const knobTranslateX = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, knobTravel],
});
const knobRotation = progress.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "180deg"],
});
const animatePress = (toValue: number) => {
Animated.timing(pressScale, {
toValue,
duration: PRESS_FEEDBACK_DURATION,
useNativeDriver: true,
}).start();
};
const handlePressIn = () => {
animatePress(PRESSED_SCALE);
};
const handlePressOut = () => {
animatePress(1);
};
const handleToggle = () => {
const next = !isOn;
const targetValue = next ? 1 : 0;
progress.stopAnimation();
removeProgressListener();
if (animationDuration <= 0) {
progress.setValue(targetValue);
setIsOn(next);
setBgOn(next);
onChange?.(next);
return;
}
setIsOn(next);
attachHalfwaySwapListener(next);
Animated.timing(progress, {
toValue: targetValue,
duration: animationDuration,
useNativeDriver: true,
}).start(() => {
setBgOn(next);
onChange?.(next);
});
};
return (
<Pressable
onPress={handleToggle}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
accessibilityRole="switch"
accessibilityState={{ checked: isOn }}
style={[styles.pressable, style]}
>
<Animated.View
style={[
styles.shadowWrapper,
{
transform: [{ scale: pressScale }],
width: containerWidth,
height: containerHeight,
borderRadius: containerHeight / 2,
},
]}
>
<View
style={[
styles.container,
{
borderRadius: containerHeight / 2,
backgroundColor: bgOn
? activeBackgroundColor
: inactiveBackgroundColor,
},
]}
>
<AnimatedImage
source={displaySource}
style={[
styles.knob,
{
width: knobSize,
height: knobSize,
borderRadius: knobSize / 2,
transform: [
{ translateX: knobTranslateX },
{ rotate: knobRotation },
],
},
]}
/>
</View>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
alignSelf: "flex-start",
},
shadowWrapper: {
justifyContent: "center",
position: "relative",
shadowColor: "#000",
shadowOpacity: 0.15,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 6,
elevation: 6,
backgroundColor: "transparent",
},
container: {
flex: 1,
justifyContent: "center",
position: "relative",
overflow: "hidden",
},
knob: {
position: "absolute",
top: 0,
left: 0,
},
});
export default RotateSwitch;

View File

@@ -1,4 +1,5 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n";
import { useTrip } from "@/state/use-trip";
import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
@@ -13,6 +14,7 @@ const CrewListTable: React.FC = () => {
const [selectedCrew, setSelectedCrew] = useState<Model.TripCrews | null>(
null
);
const { t } = useI18n();
const { trip } = useTrip();
@@ -51,7 +53,7 @@ const CrewListTable: React.FC = () => {
onPress={handleToggle}
style={styles.headerRow}
>
<Text style={styles.title}>Danh sách thuyền viên</Text>
<Text style={styles.title}>{t("trip.crewList.title")}</Text>
{collapsed && (
<Text style={styles.totalCollapsed}>{tongThanhVien}</Text>
)}
@@ -75,10 +77,12 @@ const CrewListTable: React.FC = () => {
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<View style={styles.cellWrapper}>
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.headerText]}>
{t("trip.crewList.nameHeader")}
</Text>
</View>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Chức vụ
{t("trip.crewList.roleHeader")}
</Text>
</View>
@@ -99,7 +103,9 @@ const CrewListTable: React.FC = () => {
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.footerText]}>Tổng cộng</Text>
<Text style={[styles.cell, styles.footerText]}>
{t("trip.crewList.totalLabel")}
</Text>
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
</View>
</View>
@@ -109,10 +115,12 @@ const CrewListTable: React.FC = () => {
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<View style={styles.cellWrapper}>
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.headerText]}>
{t("trip.crewList.nameHeader")}
</Text>
</View>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Chức vụ
{t("trip.crewList.roleHeader")}
</Text>
</View>
@@ -133,7 +141,9 @@ const CrewListTable: React.FC = () => {
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.footerText]}>Tổng cộng</Text>
<Text style={[styles.cell, styles.footerText]}>
{t("trip.crewList.totalLabel")}
</Text>
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
</View>
</Animated.View>

View File

@@ -1,4 +1,5 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n";
import { useTrip } from "@/state/use-trip";
import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
@@ -8,6 +9,7 @@ const FishingToolsTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current;
const { t } = useI18n();
const { trip } = useTrip();
const data: Model.FishingGear[] = trip?.fishing_gears ?? [];
@@ -31,7 +33,7 @@ const FishingToolsTable: React.FC = () => {
onPress={handleToggle}
style={styles.headerRow}
>
<Text style={styles.title}>Danh sách ngư cụ</Text>
<Text style={styles.title}>{t("trip.fishingTools.title")}</Text>
{collapsed && <Text style={styles.totalCollapsed}>{tongSoLuong}</Text>}
<IconSymbol
name={collapsed ? "chevron.down" : "chevron.up"}
@@ -52,9 +54,11 @@ const FishingToolsTable: React.FC = () => {
>
{/* Table Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.left, styles.headerText]}>
{t("trip.fishingTools.nameHeader")}
</Text>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Số lượng
{t("trip.fishingTools.quantityHeader")}
</Text>
</View>
@@ -69,7 +73,7 @@ const FishingToolsTable: React.FC = () => {
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
{t("trip.fishingTools.totalLabel")}
</Text>
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
{tongSoLuong}
@@ -81,9 +85,11 @@ const FishingToolsTable: React.FC = () => {
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
{/* Table Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.left, styles.headerText]}>
{t("trip.fishingTools.nameHeader")}
</Text>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Số lượng
{t("trip.fishingTools.quantityHeader")}
</Text>
</View>
@@ -98,7 +104,7 @@ const FishingToolsTable: React.FC = () => {
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
{t("trip.fishingTools.totalLabel")}
</Text>
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
{tongSoLuong}

View File

@@ -1,4 +1,5 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n";
import { useFishes } from "@/state/use-fish";
import { useTrip } from "@/state/use-trip";
import React, { useEffect, useRef, useState } from "react";
@@ -12,6 +13,7 @@ const NetListTable: React.FC = () => {
const animatedHeight = useRef(new Animated.Value(0)).current;
const [modalVisible, setModalVisible] = useState(false);
const [selectedNet, setSelectedNet] = useState<Model.FishingLog | null>(null);
const { t } = useI18n();
const { trip } = useTrip();
const { fishSpecies, getFishSpecies } = useFishes();
useEffect(() => {
@@ -49,7 +51,7 @@ const NetListTable: React.FC = () => {
onPress={handleToggle}
style={styles.headerRow}
>
<Text style={styles.title}>Danh sách mẻ lưới</Text>
<Text style={styles.title}>{t("trip.netList.title")}</Text>
{collapsed && (
<Text style={styles.totalCollapsed}>
{trip?.fishing_logs?.length}
@@ -84,15 +86,21 @@ const NetListTable: React.FC = () => {
>
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
<Text style={[styles.sttCell, styles.headerText]}>
{t("trip.netList.sttHeader")}
</Text>
<Text style={[styles.cell, styles.headerText]}>
{t("trip.netList.statusHeader")}
</Text>
</View>
{/* Body */}
{trip?.fishing_logs?.map((item, index) => (
<View key={item.fishing_log_id} style={styles.row}>
{/* Cột STT */}
<Text style={styles.sttCell}>Mẻ {index + 1}</Text>
<Text style={styles.sttCell}>
{t("trip.netList.haulPrefix")} {index + 1}
</Text>
{/* Cột Trạng thái */}
<View style={[styles.cell, styles.statusContainer]}>
@@ -106,7 +114,9 @@ const NetListTable: React.FC = () => {
onPress={() => handleStatusPress(item.fishing_log_id)}
>
<Text style={styles.statusText}>
{item.status ? "Đã hoàn thành" : "Chưa hoàn thành"}
{item.status
? t("trip.netList.completed")
: t("trip.netList.pending")}
</Text>
</TouchableOpacity>
</View>
@@ -118,15 +128,21 @@ const NetListTable: React.FC = () => {
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
<Text style={[styles.sttCell, styles.headerText]}>
{t("trip.netList.sttHeader")}
</Text>
<Text style={[styles.cell, styles.headerText]}>
{t("trip.netList.statusHeader")}
</Text>
</View>
{/* Body */}
{trip?.fishing_logs?.map((item, index) => (
<View key={item.fishing_log_id} style={styles.row}>
{/* Cột STT */}
<Text style={styles.sttCell}>Mẻ {index + 1}</Text>
<Text style={styles.sttCell}>
{t("trip.netList.haulPrefix")} {index + 1}
</Text>
{/* Cột Trạng thái */}
<View style={[styles.cell, styles.statusContainer]}>
@@ -140,7 +156,9 @@ const NetListTable: React.FC = () => {
onPress={() => handleStatusPress(item.fishing_log_id)}
>
<Text style={styles.statusText}>
{item.status ? "Đã hoàn thành" : "Chưa hoàn thành"}
{item.status
? t("trip.netList.completed")
: t("trip.netList.pending")}
</Text>
</TouchableOpacity>
</View>

View File

@@ -1,4 +1,5 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n";
import { useTrip } from "@/state/use-trip";
import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
@@ -14,6 +15,7 @@ const TripCostTable: React.FC = () => {
const [contentHeight, setContentHeight] = useState<number>(0);
const [modalVisible, setModalVisible] = useState(false);
const animatedHeight = useRef(new Animated.Value(0)).current;
const { t } = useI18n();
const { trip } = useTrip();
@@ -50,7 +52,7 @@ const TripCostTable: React.FC = () => {
// marginBottom: 12,
}}
>
<Text style={styles.title}>Chi phí chuyến đi</Text>
<Text style={styles.title}>{t("trip.costTable.title")}</Text>
{collapsed && (
<Text
style={[
@@ -81,9 +83,11 @@ const TripCostTable: React.FC = () => {
{/* Header */}
<View style={[styles.row, styles.header]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>
Loại
{t("trip.costTable.typeHeader")}
</Text>
<Text style={[styles.cell, styles.headerText]}>
{t("trip.costTable.totalCostHeader")}
</Text>
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
</View>
{/* Body */}
@@ -99,7 +103,7 @@ const TripCostTable: React.FC = () => {
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
{t("trip.costTable.totalLabel")}
</Text>
<Text style={[styles.cell, styles.total]}>
{tongCong.toLocaleString()}
@@ -112,7 +116,9 @@ const TripCostTable: React.FC = () => {
style={styles.viewDetailButton}
onPress={handleViewDetail}
>
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
<Text style={styles.viewDetailText}>
{t("trip.costTable.viewDetail")}
</Text>
</TouchableOpacity>
)}
</View>
@@ -121,9 +127,11 @@ const TripCostTable: React.FC = () => {
{/* Header */}
<View style={[styles.row, styles.header]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>
Loại
{t("trip.costTable.typeHeader")}
</Text>
<Text style={[styles.cell, styles.headerText]}>
{t("trip.costTable.totalCostHeader")}
</Text>
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
</View>
{/* Body */}
@@ -139,7 +147,7 @@ const TripCostTable: React.FC = () => {
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
{t("trip.costTable.totalLabel")}
</Text>
<Text style={[styles.cell, styles.total]}>
{tongCong.toLocaleString()}
@@ -152,7 +160,9 @@ const TripCostTable: React.FC = () => {
style={styles.viewDetailButton}
onPress={handleViewDetail}
>
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
<Text style={styles.viewDetailText}>
{t("trip.costTable.viewDetail")}
</Text>
</TouchableOpacity>
)}
</Animated.View>

View File

@@ -2,6 +2,7 @@ import Select from "@/components/Select";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { queryGpsData } from "@/controller/DeviceController";
import { queryUpdateFishingLogs } from "@/controller/TripController";
import { useI18n } from "@/hooks/use-i18n";
import { showErrorToast, showSuccessToast } from "@/services/toast_service";
import { useFishes } from "@/state/use-fish";
import { useTrip } from "@/state/use-trip";
@@ -46,21 +47,16 @@ const SIZE_UNITS_OPTIONS = SIZE_UNITS.map((unit) => ({
// Zod schema cho 1 dòng cá
const fishItemSchema = z.object({
id: z.number().min(1, "Chọn loài cá"),
quantity: z
.number({ invalid_type_error: "Số lượng phải là số" })
.positive("Số lượng > 0"),
unit: z.enum(UNITS, { required_error: "Chọn đơn vị" }),
size: z
.number({ invalid_type_error: "Kích thước phải là số" })
.positive("Kích thước > 0")
.optional(),
id: z.number().min(1, ""),
quantity: z.number({ invalid_type_error: "" }).positive(""),
unit: z.enum(UNITS, { required_error: "" }),
size: z.number({ invalid_type_error: "" }).positive("").optional(),
sizeUnit: z.enum(SIZE_UNITS),
});
// Schema tổng: mảng các item
const formSchema = z.object({
fish: z.array(fishItemSchema).min(1, "Thêm ít nhất 1 loài cá"),
fish: z.array(fishItemSchema).min(1, ""),
});
type FormValues = z.infer<typeof formSchema>;
@@ -78,6 +74,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
fishingLog,
fishingLogIndex,
}) => {
const { t } = useI18n();
const [isCreateMode, setIsCreateMode] = React.useState(!fishingLog?.info);
const [isEditing, setIsEditing] = React.useState(false);
const [expandedFishIndices, setExpandedFishIndices] = React.useState<
@@ -112,7 +109,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
const onSubmit = async (values: FormValues) => {
// Ensure species list is available so we can populate name/rarity
if (!fishSpecies || fishSpecies.length === 0) {
showErrorToast("Danh sách loài cá chưa sẵn sàng");
showErrorToast(t("trip.createHaulModal.fishListNotReady"));
return;
}
// Helper to map form rows -> API info entries (single place)
@@ -134,7 +131,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
try {
const gpsResp = await queryGpsData();
if (!gpsResp.data) {
showErrorToast("Không thể lấy dữ liệu GPS hiện tại");
showErrorToast(t("trip.createHaulModal.gpsError"));
return;
}
const gpsData = gpsResp.data;
@@ -177,21 +174,21 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
if (resp?.status === 200) {
showSuccessToast(
fishingLog?.fishing_log_id == null
? "Thêm mẻ cá thành công"
: "Cập nhật mẻ cá thành công"
? t("trip.createHaulModal.addSuccess")
: t("trip.createHaulModal.updateSuccess")
);
getTrip();
onClose();
} else {
showErrorToast(
fishingLog?.fishing_log_id == null
? "Thêm mẻ cá thất bại"
: "Cập nhật mẻ cá thất bại"
? t("trip.createHaulModal.addError")
: t("trip.createHaulModal.updateError")
);
}
} catch (err) {
console.error("onSubmit error:", err);
showErrorToast("Có lỗi xảy ra khi lưu mẻ cá");
showErrorToast(t("trip.createHaulModal.validationError"));
}
};
@@ -315,7 +312,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
return (
<View style={styles.fishCardHeaderContent}>
<Text style={styles.fishCardTitle}>
{fishName || "Chọn loài cá"}:
{fishName || t("trip.createHaulModal.selectFish")}:
</Text>
<Text style={styles.fishCardSubtitle}>
{fishName ? `${quantity} ${unit}` : "---"}
@@ -335,7 +332,9 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
name={`fish.${index}.id`}
render={({ field: { value, onChange } }) => (
<View style={[styles.fieldGroup, { marginTop: 20 }]}>
<Text style={styles.label}>Tên </Text>
<Text style={styles.label}>
{t("trip.createHaulModal.fishName")}
</Text>
<Select
options={fishSpecies!.map((fish) => ({
label: fish.name,
@@ -343,12 +342,12 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
}))}
value={value}
onChange={onChange}
placeholder="Chọn loài cá"
placeholder={t("trip.createHaulModal.selectFish")}
disabled={!isEditing}
/>
{errors.fish?.[index]?.id && (
<Text style={styles.errorText}>
{errors.fish[index]?.id?.message as string}
{t("trip.createHaulModal.selectFish")}
</Text>
)}
</View>
@@ -363,7 +362,9 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
name={`fish.${index}.quantity`}
render={({ field: { value, onChange, onBlur } }) => (
<View style={styles.fieldGroup}>
<Text style={styles.label}>Số lượng</Text>
<Text style={styles.label}>
{t("trip.createHaulModal.quantity")}
</Text>
<TextInput
keyboardType="numeric"
value={String(value ?? "")}
@@ -379,7 +380,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
/>
{errors.fish?.[index]?.quantity && (
<Text style={styles.errorText}>
{errors.fish[index]?.quantity?.message as string}
{t("trip.createHaulModal.quantity")}
</Text>
)}
</View>
@@ -392,7 +393,9 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
name={`fish.${index}.unit`}
render={({ field: { value, onChange } }) => (
<View style={styles.fieldGroup}>
<Text style={styles.label}>Đơn vị</Text>
<Text style={styles.label}>
{t("trip.createHaulModal.unit")}
</Text>
<Select
options={UNITS_OPTIONS.map((unit) => ({
label: unit.label,
@@ -400,13 +403,13 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
}))}
value={value}
onChange={onChange}
placeholder="Chọn đơn vị"
placeholder={t("trip.createHaulModal.unit")}
disabled={!isEditing}
listStyle={{ maxHeight: 100 }}
/>
{errors.fish?.[index]?.unit && (
<Text style={styles.errorText}>
{errors.fish[index]?.unit?.message as string}
{t("trip.createHaulModal.unit")}
</Text>
)}
</View>
@@ -423,7 +426,10 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
name={`fish.${index}.size`}
render={({ field: { value, onChange, onBlur } }) => (
<View style={styles.fieldGroup}>
<Text style={styles.label}>Kích thước</Text>
<Text style={styles.label}>
{t("trip.createHaulModal.size")} (
{t("trip.createHaulModal.optional")})
</Text>
<TextInput
keyboardType="numeric"
value={value ? String(value) : ""}
@@ -439,7 +445,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
/>
{errors.fish?.[index]?.size && (
<Text style={styles.errorText}>
{errors.fish[index]?.size?.message as string}
{t("trip.createHaulModal.size")}
</Text>
)}
</View>
@@ -452,12 +458,14 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
name={`fish.${index}.sizeUnit`}
render={({ field: { value, onChange } }) => (
<View style={styles.fieldGroup}>
<Text style={styles.label}>Đơn vị</Text>
<Text style={styles.label}>
{t("trip.createHaulModal.unit")}
</Text>
<Select
options={SIZE_UNITS_OPTIONS}
value={value}
onChange={onChange}
placeholder="Chọn đơn vị"
placeholder={t("trip.createHaulModal.unit")}
disabled={!isEditing}
listStyle={{ maxHeight: 80 }}
/>
@@ -488,7 +496,9 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>
{isCreateMode ? "Thêm mẻ cá" : "Chỉnh sửa mẻ cá"}
{isCreateMode
? t("trip.createHaulModal.addFish")
: t("trip.createHaulModal.edit")}
</Text>
<View style={styles.headerButtons}>
{isEditing ? (
@@ -504,14 +514,18 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
{ backgroundColor: "#6c757d" },
]}
>
<Text style={styles.saveButtonText}>Hủy</Text>
<Text style={styles.saveButtonText}>
{t("trip.createHaulModal.cancel")}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={handleSubmit(onSubmit)}
style={styles.saveButton}
>
<Text style={styles.saveButtonText}>Lưu</Text>
<Text style={styles.saveButtonText}>
{t("trip.createHaulModal.save")}
</Text>
</TouchableOpacity>
</>
) : (
@@ -520,7 +534,9 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
onPress={() => setIsEditing(true)}
style={[styles.saveButton, { backgroundColor: "#17a2b8" }]}
>
<Text style={styles.saveButtonText}>Sửa</Text>
<Text style={styles.saveButtonText}>
{t("trip.createHaulModal.edit")}
</Text>
</TouchableOpacity>
)
)}
@@ -546,14 +562,16 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
onPress={() => append(defaultItem())}
style={styles.addButton}
>
<Text style={styles.addButtonText}>+ Thêm loài </Text>
<Text style={styles.addButtonText}>
+ {t("trip.createHaulModal.addFish")}
</Text>
</TouchableOpacity>
)}
{/* Error Message */}
{errors.fish && (
<Text style={styles.errorText}>
{(errors.fish as any)?.message}
{t("trip.createHaulModal.validationError")}
</Text>
)}
</ScrollView>

View File

@@ -1,4 +1,5 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n";
import React from "react";
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/CrewDetailModal.styles";
@@ -21,30 +22,46 @@ const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
onClose,
crewData,
}) => {
const { t } = useI18n();
if (!crewData) return null;
const infoItems = [
{ label: "Mã định danh", value: crewData.Person.personal_id },
{ label: "Họ và tên", value: crewData.Person.name },
{ label: "Chức vụ", value: crewData.role },
{
label: "Ngày sinh",
label: t("trip.crewDetailModal.personalId"),
value: crewData.Person.personal_id,
},
{ label: t("trip.crewDetailModal.fullName"), value: crewData.Person.name },
{ label: t("trip.crewDetailModal.role"), value: crewData.role },
{
label: t("trip.crewDetailModal.birthDate"),
value: crewData.Person.birth_date
? new Date(crewData.Person.birth_date).toLocaleDateString()
: "Chưa cập nhật",
: t("trip.crewDetailModal.notUpdated"),
},
{ label: "Số điện thoại", value: crewData.Person.phone || "Chưa cập nhật" },
{ label: "Địa chỉ", value: crewData.Person.address || "Chưa cập nhật" },
{
label: "Ngày vào làm",
label: t("trip.crewDetailModal.phone"),
value: crewData.Person.phone || t("trip.crewDetailModal.notUpdated"),
},
{
label: t("trip.crewDetailModal.address"),
value: crewData.Person.address || t("trip.crewDetailModal.notUpdated"),
},
{
label: t("trip.crewDetailModal.joinedDate"),
value: crewData.joined_at
? new Date(crewData.joined_at).toLocaleDateString()
: "Chưa cập nhật",
: t("trip.crewDetailModal.notUpdated"),
},
{ label: "Ghi chú", value: crewData.note || "Chưa cập nhật" },
{
label: "Tình trạng",
value: crewData.left_at ? "Đã nghỉ" : "Đang làm việc",
label: t("trip.crewDetailModal.note"),
value: crewData.note || t("trip.crewDetailModal.notUpdated"),
},
{
label: t("trip.crewDetailModal.status"),
value: crewData.left_at
? t("trip.crewDetailModal.resigned")
: t("trip.crewDetailModal.working"),
},
];
@@ -58,7 +75,7 @@ const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Thông tin thuyền viên</Text>
<Text style={styles.title}>{t("trip.crewDetailModal.title")}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<View style={styles.closeIconButton}>
<IconSymbol name="xmark" size={28} color="#fff" />

View File

@@ -1,3 +1,4 @@
import { useI18n } from "@/hooks/use-i18n";
import React from "react";
import { Text, View } from "react-native";
import styles from "../../style/NetDetailModal.styles";
@@ -11,24 +12,31 @@ export const InfoSection: React.FC<InfoSectionProps> = ({
fishingLog,
stt,
}) => {
const { t } = useI18n();
if (!fishingLog) {
return null;
}
const infoItems = [
{ label: "Số thứ tự", value: `Mẻ ${stt}` },
{
label: "Trạng thái",
value: fishingLog.status === 1 ? "Đã hoàn thành" : "Chưa hoàn thành",
label: t("trip.infoSection.sttLabel"),
value: `${t("trip.infoSection.haulPrefix")} ${stt}`,
},
{
label: t("trip.infoSection.statusLabel"),
value:
fishingLog.status === 1
? t("trip.infoSection.statusCompleted")
: t("trip.infoSection.statusPending"),
isStatus: true,
},
{
label: "Thời gian bắt đầu",
label: t("trip.infoSection.startTimeLabel"),
value: fishingLog.start_at
? new Date(fishingLog.start_at).toLocaleString()
: "Chưa cập nhật",
: t("trip.infoSection.notUpdated"),
},
{
label: "Thời gian kết thúc",
label: t("trip.infoSection.endTimeLabel"),
value:
fishingLog.end_at.toString() !== "0001-01-01T00:00:00Z"
? new Date(fishingLog.end_at).toLocaleString()

View File

@@ -1,4 +1,5 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n";
import React, { useEffect, useState } from "react";
import {
KeyboardAvoidingView,
@@ -29,6 +30,7 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
onClose,
data,
}) => {
const { t } = useI18n();
const [isEditing, setIsEditing] = useState(false);
const [editableData, setEditableData] = useState<Model.TripCost[]>(data);
@@ -94,7 +96,7 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Chi tiết chi phí chuyến đi</Text>
<Text style={styles.title}>{t("trip.costDetailModal.title")}</Text>
<View style={styles.headerButtons}>
{isEditing ? (
<>
@@ -102,13 +104,17 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
onPress={handleCancel}
style={styles.cancelButton}
>
<Text style={styles.cancelButtonText}>Hủy</Text>
<Text style={styles.cancelButtonText}>
{t("trip.costDetailModal.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
style={styles.saveButton}
>
<Text style={styles.saveButtonText}>Lưu</Text>
<Text style={styles.saveButtonText}>
{t("trip.costDetailModal.save")}
</Text>
</TouchableOpacity>
</>
) : (
@@ -140,13 +146,15 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
<View key={index} style={styles.itemCard}>
{/* Loại */}
<View style={styles.fieldGroup}>
<Text style={styles.label}>Loại chi phí</Text>
<Text style={styles.label}>
{t("trip.costDetailModal.costType")}
</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={item.type}
onChangeText={(value) => updateItem(index, "type", value)}
editable={isEditing}
placeholder="Nhập loại chi phí"
placeholder={t("trip.costDetailModal.enterCostType")}
/>
</View>
@@ -155,7 +163,9 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
<View
style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}
>
<Text style={styles.label}>Số lượng</Text>
<Text style={styles.label}>
{t("trip.costDetailModal.quantity")}
</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={String(item.amount ?? "")}
@@ -168,20 +178,24 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
/>
</View>
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Đơn vị</Text>
<Text style={styles.label}>
{t("trip.costDetailModal.unit")}
</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={item.unit}
onChangeText={(value) => updateItem(index, "unit", value)}
editable={isEditing}
placeholder="kg, lít..."
placeholder={t("trip.costDetailModal.placeholder")}
/>
</View>
</View>
{/* Chi phí/đơn vị */}
<View style={styles.fieldGroup}>
<Text style={styles.label}>Chi phí/đơn vị (VNĐ)</Text>
<Text style={styles.label}>
{t("trip.costDetailModal.costPerUnit")}
</Text>
<TextInput
style={[styles.input, !isEditing && styles.inputDisabled]}
value={String(item.cost_per_unit ?? "")}
@@ -196,10 +210,13 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
{/* Tổng chi phí */}
<View style={styles.fieldGroup}>
<Text style={styles.label}>Tổng chi phí</Text>
<Text style={styles.label}>
{t("trip.costDetailModal.totalCost")}
</Text>
<View style={styles.totalContainer}>
<Text style={styles.totalText}>
{item.total_cost.toLocaleString()} VNĐ
{item.total_cost.toLocaleString()}{" "}
{t("trip.costDetailModal.vnd")}
</Text>
</View>
</View>
@@ -208,9 +225,11 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
{/* Footer Total */}
<View style={styles.footerTotal}>
<Text style={styles.footerLabel}>Tổng cộng</Text>
<Text style={styles.footerLabel}>
{t("trip.costDetailModal.total")}
</Text>
<Text style={styles.footerAmount}>
{tongCong.toLocaleString()} VNĐ
{tongCong.toLocaleString()} {t("trip.costDetailModal.vnd")}
</Text>
</View>
</ScrollView>

View File

@@ -0,0 +1,246 @@
import { Ionicons } from "@expo/vector-icons";
import type { ComponentProps } from "react";
import { useRef, useState } from "react";
import {
Animated,
OpaqueColorValue,
Pressable,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from "react-native";
const SIZE_PRESETS = {
sm: { width: 64, height: 32 },
md: { width: 80, height: 40 },
lg: { width: 96, height: 48 },
} as const;
type SwitchSize = keyof typeof SIZE_PRESETS;
const DEFAULT_TOGGLE_DURATION = 400;
// Default both backgrounds to a grey tone when not provided
const DEFAULT_INACTIVE_BG = "#D3DAD9";
const DEFAULT_ACTIVE_BG = "#D3DAD9";
const PRESSED_SCALE = 0.96;
const PRESS_FEEDBACK_DURATION = 120;
type IoniconName = ComponentProps<typeof Ionicons>["name"];
type RotateSwitchProps = {
size?: SwitchSize;
leftIcon?: IoniconName;
leftIconColor?: string | OpaqueColorValue | undefined;
rightIconColor?: string | OpaqueColorValue | undefined;
rightIcon?: IoniconName;
duration?: number;
activeBackgroundColor?: string;
inactiveBackgroundColor?: string;
inactiveOverlayColor?: string;
activeOverlayColor?: string;
style?: StyleProp<ViewStyle>;
onChange?: (value: boolean) => void;
};
const SliceSwitch = ({
size = "md",
leftIcon,
rightIcon,
duration,
activeBackgroundColor = DEFAULT_ACTIVE_BG,
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
leftIconColor = "#fff",
rightIconColor = "#fff",
inactiveOverlayColor = "#000",
activeOverlayColor = "#000",
style,
onChange,
}: RotateSwitchProps) => {
const { width: containerWidth, height: containerHeight } =
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
const [isOn, setIsOn] = useState(false);
const [bgOn, setBgOn] = useState(false);
const progress = useRef(new Animated.Value(0)).current;
const pressScale = useRef(new Animated.Value(1)).current;
const overlayTranslateX = useRef(new Animated.Value(0)).current;
const listenerIdRef = useRef<string | number | null>(null);
const handleToggle = () => {
const next = !isOn;
const targetValue = next ? 1 : 0;
const overlayTarget = next ? containerWidth / 2 : 0;
progress.stopAnimation();
overlayTranslateX.stopAnimation();
if (animationDuration <= 0) {
progress.setValue(targetValue);
overlayTranslateX.setValue(overlayTarget);
setIsOn(next);
setBgOn(next);
onChange?.(next);
return;
}
setIsOn(next);
Animated.parallel([
Animated.timing(progress, {
toValue: targetValue,
duration: animationDuration,
useNativeDriver: true,
}),
Animated.timing(overlayTranslateX, {
toValue: overlayTarget,
duration: animationDuration,
useNativeDriver: true,
}),
]).start(() => {
setBgOn(next);
onChange?.(next);
});
// Remove any previous listener
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
// Swap image & background exactly at 50% progress
let swapped = false;
listenerIdRef.current = progress.addListener(({ value }) => {
if (swapped) return;
if (next && value >= 0.5) {
swapped = true;
setBgOn(next);
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
}
if (!next && value <= 0.5) {
swapped = true;
setBgOn(next);
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
}
});
};
const handlePressIn = () => {
pressScale.stopAnimation();
Animated.timing(pressScale, {
toValue: PRESSED_SCALE,
duration: PRESS_FEEDBACK_DURATION,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
pressScale.stopAnimation();
Animated.timing(pressScale, {
toValue: 1,
duration: PRESS_FEEDBACK_DURATION,
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={handleToggle}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
accessibilityRole="switch"
accessibilityState={{ checked: isOn }}
style={[styles.pressable, style]}
>
<Animated.View
style={[
styles.shadowWrapper,
{
transform: [{ scale: pressScale }],
width: containerWidth,
height: containerHeight,
borderRadius: containerHeight / 2,
},
]}
>
<View
style={[
styles.container,
{
flexDirection: "row",
justifyContent: "space-between",
borderRadius: containerHeight / 2,
backgroundColor: bgOn
? activeBackgroundColor
: inactiveBackgroundColor,
},
]}
>
<Animated.View
style={{
position: "absolute",
width: containerWidth / 2,
height: containerHeight * 0.95,
top: containerHeight * 0.01,
left: 0,
borderRadius: containerHeight * 0.95 / 2,
zIndex: 10,
backgroundColor: bgOn ? activeOverlayColor : inactiveOverlayColor,
transform: [{ translateX: overlayTranslateX }],
}}
/>
<View className="h-full w-1/2 items-center justify-center ">
<Ionicons
name={leftIcon ?? "sunny"}
size={20}
color={leftIconColor ?? "#fff"}
/>
</View>
<View className="h-full w-1/2 items-center justify-center ">
<Ionicons
name={rightIcon ?? "moon"}
size={20}
color={rightIconColor ?? "#fff"}
/>
</View>
</View>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
alignSelf: "flex-start",
},
shadowWrapper: {
justifyContent: "center",
position: "relative",
shadowColor: "#000",
shadowOpacity: 0.15,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 6,
elevation: 6,
backgroundColor: "transparent",
},
container: {
flex: 1,
justifyContent: "center",
position: "relative",
overflow: "hidden",
},
knob: {
position: "absolute",
top: 0,
left: 0,
},
});
export default SliceSwitch;