add en/vi language
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
307
components/rotate-switch.tsx
Normal file
307
components/rotate-switch.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 cá</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 cá</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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
246
components/ui/slice-switch.tsx
Normal file
246
components/ui/slice-switch.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user