diff --git a/LOCALIZATION.md b/LOCALIZATION.md
new file mode 100644
index 0000000..e4c7bcb
--- /dev/null
+++ b/LOCALIZATION.md
@@ -0,0 +1,224 @@
+# Localization Setup Guide
+
+## Overview
+
+Ứng dụng đã được cấu hình hỗ trợ 2 ngôn ngữ: **English (en)** và **Tiếng Việt (vi)** sử dụng `expo-localization` và `i18n-js`.
+
+## Cấu trúc thư mục
+
+```
+/config
+ /localization
+ - i18n.ts # Cấu hình i18n (khởi tạo locale, enable fallback, etc.)
+ - localization.ts # Export main exports
+
+/locales
+ - en.json # Các string tiếng Anh
+ - vi.json # Các string tiếng Việt
+
+/hooks
+ - use-i18n.ts # Hook để sử dụng i18n trong components
+
+/state
+ - use-locale-store.ts # Zustand store cho global locale state management
+```
+
+## Cách sử dụng
+
+### 1. Import i18n hook trong component
+
+```tsx
+import { useI18n } from "@/hooks/use-i18n";
+
+export default function MyComponent() {
+ const { t, locale, setLocale } = useI18n();
+
+ return (
+
+ {t("common.ok")}
+ {t("navigation.home")}
+ Current locale: {locale}
+
+ );
+}
+```
+
+### 2. Thêm translation keys
+
+#### Mở file `/locales/en.json` và thêm:
+
+```json
+{
+ "myFeature": {
+ "title": "My Feature Title",
+ "description": "My Feature Description"
+ }
+}
+```
+
+#### Mở file `/locales/vi.json` và thêm translation tương ứng:
+
+```json
+{
+ "myFeature": {
+ "title": "Tiêu đề Tính năng Của Tôi",
+ "description": "Mô tả Tính năng Của Tôi"
+ }
+}
+```
+
+### 3. Sử dụng trong component
+
+```tsx
+const { t } = useI18n();
+
+return {t("myFeature.title")} ;
+```
+
+## Cách thay đổi ngôn ngữ
+
+```tsx
+const { setLocale } = useI18n();
+
+// Thay đổi sang Tiếng Việt
+ setLocale('vi')} title="Tiếng Việt" />
+
+// Thay đổi sang English
+ setLocale('en')} title="English" />
+```
+
+**Lưu ý:** Ngôn ngữ được chọn sẽ được **lưu tự động vào storage**. Khi người dùng tắt app và mở lại, app sẽ sử dụng ngôn ngữ được chọn trước đó.
+
+## Persistence (Lưu trữ tùy chọn ngôn ngữ) - Zustand Global State
+
+Localization sử dụng **Zustand** cho global state management. Ngôn ngữ được chọn tự động được lưu vào `AsyncStorage` với key `app_locale_preference`.
+
+**Quy trình:**
+
+1. Khi app khởi động, `useLocaleStore.getState().initLocale()` được gọi trong `app/_layout.tsx`
+2. Store sẽ load locale từ storage nếu có
+3. Nếu không có, store sẽ detect ngôn ngữ thiết bị (`getLocales()`)
+4. Khi người dùng gọi `setLocale('vi')`, nó sẽ:
+ - Cập nhật Zustand store ngay lập tức
+ - **Tự động lưu vào storage** để dùng lần tiếp theo
+ - Tất cả components lắng nghe sẽ re-render ngay lập tức
+
+**Kết quả:** Khi bạn toggle switch language trong settings:
+
+- ✅ Tab labels cập nhật ngay lập tức
+- ✅ UI labels cập nhật ngay lập tức
+- ✅ Không cần click vào tab hoặc navigate
+- ✅ Locale được persist vào storage
+
+### Zustand Store Structure
+
+```tsx
+// /state/use-locale-store.ts
+const { locale, setLocale, isLoaded } = useLocaleStore((state) => ({
+ locale: state.locale, // Locale hiện tại
+ setLocale: state.setLocale, // Async function để thay đổi locale
+ isLoaded: state.isLoaded, // Flag để biết khi locale đã load từ storage
+}));
+```
+
+## Fallback Mechanism
+
+Nếu một key không tồn tại trong ngôn ngữ hiện tại, ứng dụng sẽ tự động sử dụng giá trị từ ngôn ngữ English (default locale).
+
+### Ví dụ:
+
+- Nếu key `auth.newFeature` chỉ tồn tại trong `en.json` mà không có trong `vi.json`
+- Khi ngôn ngữ được set là Vietnamese (`vi`), nó sẽ hiển thị giá trị từ English
+
+## Persistence (Lưu trữ tùy chọn ngôn ngữ)
+
+Ngôn ngữ được chọn tự động được lưu vào `AsyncStorage` với key `app_locale_preference`.
+
+**Quy trình:**
+
+1. Khi app khởi động, hook `useI18n` sẽ load giá trị từ storage
+2. Nếu có giá trị lưu trữ, app sẽ sử dụng ngôn ngữ đó
+3. Nếu không có, app sẽ detect ngôn ngữ thiết bị (`getLocales()`)
+4. Khi người dùng gọi `setLocale('vi')`, nó sẽ:
+ - Thay đổi ngôn ngữ hiện tại
+ - **Tự động lưu vào storage** để dùng lần tiếp theo
+
+**Kết quả:** Người dùng có thể tắt app, mở lại, và app sẽ vẫn sử dụng ngôn ngữ đã chọn trước đó.
+
+## Supported Locales
+
+Hiện tại app hỗ trợ:
+
+- **en** - English
+- **vi** - Tiếng Việt
+
+Nếu muốn thêm ngôn ngữ khác:
+
+1. Tạo file `locales/[language-code].json` (ví dụ: `locales/ja.json`)
+2. Thêm translations
+3. Import trong `config/localization/i18n.ts`:
+
+ ```ts
+ import ja from "@/locales/ja.json";
+
+ const translations = {
+ en,
+ vi,
+ ja,
+ };
+ ```
+
+4. Cập nhật `app.json` để thêm locale mới:
+ ```json
+ "supportedLocales": {
+ "ios": ["en", "vi", "ja"],
+ "android": ["en", "vi", "ja"]
+ }
+ ```
+
+## Translation Keys Hiện Có
+
+Xem file `locales/en.json` và `locales/vi.json` để xem danh sách tất cả keys có sẵn.
+
+## Best Practices
+
+1. **Luôn add key vào cả 2 file** `en.json` và `vi.json` cùng lúc
+2. **Giữ cấu trúc JSON nhất quán** giữa các language files
+3. **Sử dụng snake_case cho keys** (không sử dụng camelCase)
+4. **Nhóm liên quan keys** vào categories (ví dụ: `common`, `navigation`, `auth`, etc.)
+5. **Để fallback enable** để tránh lỗi nếu key bị thiếu
+
+## Device Language Detection
+
+Ngôn ngữ của thiết bị sẽ được tự động detect khi app khởi động. Nếu thiết bị set language là Tiếng Việt, app sẽ tự động sử dụng `vi` locale.
+
+Device language được lấy từ:
+
+```tsx
+import { getLocales } from "expo-localization";
+
+const deviceLanguage = getLocales()[0].languageCode; // 'en', 'vi', etc.
+```
+
+## Troubleshooting
+
+### App không detect đúng ngôn ngữ thiết bị
+
+- Kiểm tra console log trong `config/localization/i18n.ts`
+- Đảm bảo language code của thiết bị khớp với các supported locales
+
+### Translation key không hiện
+
+- Kiểm tra xem key có tồn tại trong cả 2 files `en.json` và `vi.json` không
+- Kiểm tra spelling của key (case-sensitive)
+- Kiểm tra syntax JSON có hợp lệ không
+
+### Fallback không hoạt động
+
+- Đảm bảo `i18n.enableFallback = true` trong `config/localization/i18n.ts`
+- Kiểm tra key có tồn tại trong `en.json` không (default fallback language)
+
+## References
+
+- [Expo Localization Guide](https://docs.expo.dev/guides/localization/)
+- [i18n-js Documentation](https://github.com/fnando/i18n-js)
diff --git a/app.json b/app.json
index 18e91c5..23d0263 100644
--- a/app.json
+++ b/app.json
@@ -45,6 +45,15 @@
{
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
}
+ ],
+ [
+ "expo-localization",
+ {
+ "supportedLocales": {
+ "ios": ["en", "vi"],
+ "android": ["en", "vi"]
+ }
+ }
]
],
"experiments": {
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 3fb295b..942c2e6 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -4,14 +4,16 @@ import { HapticTab } from "@/components/haptic-tab";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme";
+import { useI18n } from "@/hooks/use-i18n";
import { startEvents, stopEvents } from "@/services/device_events";
import { useEffect, useRef } from "react";
export default function TabLayout() {
const colorScheme = useColorScheme();
- const segments = useSegments();
+ const segments = useSegments() as string[];
const prev = useRef(null);
- const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null; // tuỳ cấu trúc của bạn
+ const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
+ const { t, locale } = useI18n();
useEffect(() => {
if (prev.current !== currentSegment) {
// console.log("Tab changed ->", { from: prev.current, to: currentSegment });
@@ -39,7 +41,7 @@ export default function TabLayout() {
(
),
@@ -49,7 +51,7 @@ export default function TabLayout() {
(
),
@@ -58,7 +60,7 @@ export default function TabLayout() {
(
),
@@ -67,7 +69,7 @@ export default function TabLayout() {
(
(
),
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index c3343d7..7047998 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -91,7 +91,7 @@ export default function HomeScreen() {
if (TrackPointsData && TrackPointsData.length > 0) {
setTrackPointsData(TrackPointsData);
} else {
- setTrackPointsData(null);
+ setTrackPointsData([]);
}
};
diff --git a/app/(tabs)/setting.tsx b/app/(tabs)/setting.tsx
index 0b53a3a..f2b1d18 100644
--- a/app/(tabs)/setting.tsx
+++ b/app/(tabs)/setting.tsx
@@ -1,13 +1,15 @@
import { useRouter } from "expo-router";
-import { StyleSheet } from "react-native";
+import { useEffect, useState } from "react";
+import { StyleSheet, View } from "react-native";
+import EnIcon from "@/assets/icons/en_icon.png";
+import VnIcon from "@/assets/icons/vi_icon.png";
+import RotateSwitch from "@/components/rotate-switch";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
-import { api } from "@/config";
import { DOMAIN, TOKEN } from "@/constants";
+import { useI18n } from "@/hooks/use-i18n";
import { removeStorageItem } from "@/utils/storage";
-import { useState } from "react";
-
type Todo = {
userId: number;
id: number;
@@ -18,23 +20,40 @@ type Todo = {
export default function SettingScreen() {
const router = useRouter();
const [data, setData] = useState(null);
+ const { t, locale, setLocale } = useI18n();
+ const [isEnabled, setIsEnabled] = useState(locale === "vi");
+ // Sync isEnabled state khi locale thay đổi
+ useEffect(() => {
+ setIsEnabled(locale === "vi");
+ }, [locale]);
- // useEffect(() => {
- // getData();
- // }, []);
-
- const getData = async () => {
- try {
- const response = await api.get("/todos/1");
- setData(response.data);
- } catch (error) {
- console.error("Error fetching data:", error);
- }
+ const toggleSwitch = async () => {
+ const newLocale = isEnabled ? "en" : "vi";
+ await setLocale(newLocale);
};
return (
- Settings
+ {t("navigation.setting")}
+
+
+ {t("common.language")}
+ {/* */}
+
+
+
{
@@ -43,8 +62,9 @@ export default function SettingScreen() {
router.navigate("/auth/login");
}}
>
- Đăng xuất
+ {t("auth.logout")}
+
{data && (
{data.title}
@@ -62,11 +82,23 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
padding: 20,
+ gap: 16,
+ },
+ settingItem: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ width: "100%",
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderRadius: 8,
+ backgroundColor: "rgba(0, 122, 255, 0.1)",
},
button: {
marginTop: 20,
- padding: 10,
+ paddingVertical: 12,
+ paddingHorizontal: 20,
backgroundColor: "#007AFF",
- borderRadius: 5,
+ borderRadius: 8,
},
});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 84af030..e127d73 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -14,6 +14,7 @@ import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth";
import "@/global.css";
import { useColorScheme } from "@/hooks/use-color-scheme";
+import { I18nProvider } from "@/hooks/use-i18n";
import Toast from "react-native-toast-message";
import "../global.css";
export default function RootLayout() {
@@ -23,38 +24,41 @@ export default function RootLayout() {
useEffect(() => {
setRouterInstance(router);
}, [router]);
-
return (
-
-
-
+
+
-
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/app/auth/login.tsx b/app/auth/login.tsx
index 77ca28d..fde03fb 100644
--- a/app/auth/login.tsx
+++ b/app/auth/login.tsx
@@ -1,8 +1,13 @@
+import EnIcon from "@/assets/icons/en_icon.png";
+import VnIcon from "@/assets/icons/vi_icon.png";
+import RotateSwitch from "@/components/rotate-switch";
import ScanQRCode from "@/components/ScanQRCode";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
+import SliceSwitch from "@/components/ui/slice-switch";
import { DOMAIN, TOKEN } from "@/constants";
import { login } from "@/controller/AuthController";
+import { useI18n } from "@/hooks/use-i18n";
import { showErrorToast, showWarningToast } from "@/services/toast_service";
import {
getStorageItem,
@@ -25,7 +30,6 @@ import {
TouchableOpacity,
View,
} from "react-native";
-
export default function LoginScreen() {
const router = useRouter();
const [username, setUsername] = useState("");
@@ -33,6 +37,8 @@ export default function LoginScreen() {
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [isShowingQRScanner, setIsShowingQRScanner] = useState(false);
+ const { t, setLocale, locale } = useI18n();
+ const [isVNLang, setIsVNLang] = useState(false);
const checkLogin = useCallback(async () => {
const token = await getStorageItem(TOKEN);
@@ -47,7 +53,6 @@ export default function LoginScreen() {
return;
}
const parsed = parseJwtToken(token);
- // console.log("Parse Token: ", parsed);
const { exp } = parsed;
const now = Math.floor(Date.now() / 1000);
@@ -60,6 +65,10 @@ export default function LoginScreen() {
}
}, [router]);
+ useEffect(() => {
+ setIsVNLang(locale === "vi");
+ }, [locale]);
+
useEffect(() => {
checkLogin();
}, [checkLogin]);
@@ -131,6 +140,15 @@ export default function LoginScreen() {
}
};
+ const handleSwitchLanguage = (isVN: boolean) => {
+ if (isVN) {
+ setLocale("vi");
+ } else {
+ setLocale("en");
+ }
+ setIsVNLang(isVN);
+ };
+
return (
- Hệ thống giám sát tàu cá
-
-
- Đăng nhập để tiếp tục
+ {t("common.app_name")}
@@ -158,10 +173,10 @@ export default function LoginScreen() {
{/* Username Input */}
- Tài khoản
+ {t("auth.username")}
- Mật khẩu
+ {t("auth.password")}
) : (
- Đăng nhập
+ {t("auth.login")}
)}
@@ -255,18 +270,32 @@ export default function LoginScreen() {
- {/* Footer text */}
-
-
- Chưa có tài khoản?{" "}
- Đăng ký ngay
-
+ {/* Language Switcher */}
+
+
+
{/* Copyright */}
- © {new Date().getFullYear()} - Sản phẩm của Mobifone
+ © {new Date().getFullYear()} - {t("common.footer_text")}
@@ -364,4 +393,46 @@ const styles = StyleSheet.create({
opacity: 0.6,
textAlign: "center",
},
+ languageSwitcherContainer: {
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "center",
+
+ alignItems: "center",
+ marginTop: 24,
+ gap: 20,
+ },
+ languageSwitcherLabel: {
+ fontSize: 12,
+ fontWeight: "600",
+ textAlign: "center",
+ opacity: 0.8,
+ },
+ languageButtonsContainer: {
+ flexDirection: "row",
+ gap: 10,
+ },
+ languageButton: {
+ flex: 1,
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ borderWidth: 1,
+ borderColor: "#ddd",
+ borderRadius: 8,
+ alignItems: "center",
+ backgroundColor: "transparent",
+ },
+ languageButtonActive: {
+ backgroundColor: "#007AFF",
+ borderColor: "#007AFF",
+ },
+ languageButtonText: {
+ fontSize: 14,
+ fontWeight: "500",
+ color: "#666",
+ },
+ languageButtonTextActive: {
+ color: "#fff",
+ fontWeight: "600",
+ },
});
diff --git a/assets/icons/en_icon.png b/assets/icons/en_icon.png
new file mode 100644
index 0000000..8be3aa9
Binary files /dev/null and b/assets/icons/en_icon.png differ
diff --git a/assets/icons/vi_icon.png b/assets/icons/vi_icon.png
new file mode 100644
index 0000000..249be61
Binary files /dev/null and b/assets/icons/vi_icon.png differ
diff --git a/components/ButtonCancelTrip.tsx b/components/ButtonCancelTrip.tsx
index 7b2f646..0fb675b 100644
--- a/components/ButtonCancelTrip.tsx
+++ b/components/ButtonCancelTrip.tsx
@@ -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 = ({
- title = "Hủy chuyến đi",
+ title,
onPress,
}) => {
+ const { t } = useI18n();
+ const displayTitle = title || t("trip.buttonCancelTrip.title");
return (
- {title}
+ {displayTitle}
);
};
diff --git a/components/ButtonCreateNewHaulOrTrip.tsx b/components/ButtonCreateNewHaulOrTrip.tsx
index 97270a9..788e174 100644
--- a/components/ButtonCreateNewHaulOrTrip.tsx
+++ b/components/ButtonCreateNewHaulOrTrip.tsx
@@ -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 = ({
}) => {
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 = ({
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 = ({
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 = ({
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 = ({
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 = ({
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 = ({
style={{ backgroundColor: "green", borderRadius: 10 }}
onPress={async () => handleStartTrip(3)}
>
- Bắt đầu chuyến đi
+ {t("trip.startTrip")}
) : checkHaulFinished() ? (
= ({
style={{ borderRadius: 10 }}
onPress={() => setIsFinishHaulModalOpen(true)}
>
- Kết thúc mẻ lưới
+ {t("trip.endHaul")}
) : (
= ({
createNewHaul();
}}
>
- Bắt đầu mẻ lưới
+ {t("trip.startHaul")}
)}
void;
}
-const ButtonEndTrip: React.FC = ({
- title = "Kết thúc",
- onPress,
-}) => {
+const ButtonEndTrip: React.FC = ({ title, onPress }) => {
+ const { t } = useI18n();
+ const displayTitle = title || t("trip.buttonEndTrip.title");
return (
- {title}
+ {displayTitle}
);
};
diff --git a/components/map/GPSInfoPanel.tsx b/components/map/GPSInfoPanel.tsx
index a6a53bb..80e9094 100644
--- a/components/map/GPSInfoPanel.tsx
+++ b/components/map/GPSInfoPanel.tsx
@@ -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) => {
@@ -102,12 +103,15 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
-
+
diff --git a/components/map/SosButton.tsx b/components/map/SosButton.tsx
index 0e1b0ae..e54c66b 100644
--- a/components/map/SosButton.tsx
+++ b/components/map/SosButton.tsx
@@ -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 = () => {
>
- {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")}
{/* */}
{/* */}
@@ -142,14 +143,14 @@ const SosButton = () => {
- Thông báo khẩn cấp
+ {t("home.sos.title")}
{/* Dropdown Nội dung SOS */}
- Nội dung:
+ {t("home.sos.content")}
{
>
{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")}
{
{/* Input Custom Message nếu chọn "Khác" */}
{selectedSosMessage === 999 && (
- Nhập trạng thái
+ {t("home.sos.statusInput")}
{
@@ -217,7 +218,7 @@ const SosButton = () => {
// className="w-1/3"
action="negative"
>
- Xác nhận
+ {t("home.sos.confirm")}
{
@@ -229,7 +230,7 @@ const SosButton = () => {
// className="w-1/3"
action="secondary"
>
- Hủy
+ {t("home.sos.cancel")}
diff --git a/components/rotate-switch.tsx b/components/rotate-switch.tsx
new file mode 100644
index 0000000..63b65a2
--- /dev/null
+++ b/components/rotate-switch.tsx
@@ -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;
+ 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(
+ initialValue ? resolvedOnImage : resolvedOffImage
+ );
+
+ const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current;
+ const pressScale = useRef(new Animated.Value(1)).current;
+ const listenerIdRef = useRef(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 (
+
+
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/components/tripInfo/CrewListTable.tsx b/components/tripInfo/CrewListTable.tsx
index 3a33202..a8f6375 100644
--- a/components/tripInfo/CrewListTable.tsx
+++ b/components/tripInfo/CrewListTable.tsx
@@ -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(
null
);
+ const { t } = useI18n();
const { trip } = useTrip();
@@ -51,7 +53,7 @@ const CrewListTable: React.FC = () => {
onPress={handleToggle}
style={styles.headerRow}
>
- Danh sách thuyền viên
+ {t("trip.crewList.title")}
{collapsed && (
{tongThanhVien}
)}
@@ -75,10 +77,12 @@ const CrewListTable: React.FC = () => {
{/* Header */}
- Tên
+
+ {t("trip.crewList.nameHeader")}
+
- Chức vụ
+ {t("trip.crewList.roleHeader")}
@@ -99,7 +103,9 @@ const CrewListTable: React.FC = () => {
{/* Footer */}
- Tổng cộng
+
+ {t("trip.crewList.totalLabel")}
+
{tongThanhVien}
@@ -109,10 +115,12 @@ const CrewListTable: React.FC = () => {
{/* Header */}
- Tên
+
+ {t("trip.crewList.nameHeader")}
+
- Chức vụ
+ {t("trip.crewList.roleHeader")}
@@ -133,7 +141,9 @@ const CrewListTable: React.FC = () => {
{/* Footer */}
- Tổng cộng
+
+ {t("trip.crewList.totalLabel")}
+
{tongThanhVien}
diff --git a/components/tripInfo/FishingToolsList.tsx b/components/tripInfo/FishingToolsList.tsx
index afc4d21..ed283dd 100644
--- a/components/tripInfo/FishingToolsList.tsx
+++ b/components/tripInfo/FishingToolsList.tsx
@@ -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(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}
>
- Danh sách ngư cụ
+ {t("trip.fishingTools.title")}
{collapsed && {tongSoLuong} }
{
>
{/* Table Header */}
- Tên
+
+ {t("trip.fishingTools.nameHeader")}
+
- Số lượng
+ {t("trip.fishingTools.quantityHeader")}
@@ -69,7 +73,7 @@ const FishingToolsTable: React.FC = () => {
{/* Footer */}
- Tổng cộng
+ {t("trip.fishingTools.totalLabel")}
{tongSoLuong}
@@ -81,9 +85,11 @@ const FishingToolsTable: React.FC = () => {
{/* Table Header */}
- Tên
+
+ {t("trip.fishingTools.nameHeader")}
+
- Số lượng
+ {t("trip.fishingTools.quantityHeader")}
@@ -98,7 +104,7 @@ const FishingToolsTable: React.FC = () => {
{/* Footer */}
- Tổng cộng
+ {t("trip.fishingTools.totalLabel")}
{tongSoLuong}
diff --git a/components/tripInfo/NetListTable.tsx b/components/tripInfo/NetListTable.tsx
index 952c3a6..9cd6b70 100644
--- a/components/tripInfo/NetListTable.tsx
+++ b/components/tripInfo/NetListTable.tsx
@@ -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(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}
>
- Danh sách mẻ lưới
+ {t("trip.netList.title")}
{collapsed && (
{trip?.fishing_logs?.length}
@@ -84,15 +86,21 @@ const NetListTable: React.FC = () => {
>
{/* Header */}
- STT
- Trạng thái
+
+ {t("trip.netList.sttHeader")}
+
+
+ {t("trip.netList.statusHeader")}
+
{/* Body */}
{trip?.fishing_logs?.map((item, index) => (
{/* Cột STT */}
- Mẻ {index + 1}
+
+ {t("trip.netList.haulPrefix")} {index + 1}
+
{/* Cột Trạng thái */}
@@ -106,7 +114,9 @@ const NetListTable: React.FC = () => {
onPress={() => handleStatusPress(item.fishing_log_id)}
>
- {item.status ? "Đã hoàn thành" : "Chưa hoàn thành"}
+ {item.status
+ ? t("trip.netList.completed")
+ : t("trip.netList.pending")}
@@ -118,15 +128,21 @@ const NetListTable: React.FC = () => {
{/* Header */}
- STT
- Trạng thái
+
+ {t("trip.netList.sttHeader")}
+
+
+ {t("trip.netList.statusHeader")}
+
{/* Body */}
{trip?.fishing_logs?.map((item, index) => (
{/* Cột STT */}
- Mẻ {index + 1}
+
+ {t("trip.netList.haulPrefix")} {index + 1}
+
{/* Cột Trạng thái */}
@@ -140,7 +156,9 @@ const NetListTable: React.FC = () => {
onPress={() => handleStatusPress(item.fishing_log_id)}
>
- {item.status ? "Đã hoàn thành" : "Chưa hoàn thành"}
+ {item.status
+ ? t("trip.netList.completed")
+ : t("trip.netList.pending")}
diff --git a/components/tripInfo/TripCostTable.tsx b/components/tripInfo/TripCostTable.tsx
index c6c6c71..41b2eb4 100644
--- a/components/tripInfo/TripCostTable.tsx
+++ b/components/tripInfo/TripCostTable.tsx
@@ -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(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,
}}
>
- Chi phí chuyến đi
+ {t("trip.costTable.title")}
{collapsed && (
{
{/* Header */}
- Loại
+ {t("trip.costTable.typeHeader")}
+
+
+ {t("trip.costTable.totalCostHeader")}
- Tổng chi phí
{/* Body */}
@@ -99,7 +103,7 @@ const TripCostTable: React.FC = () => {
{/* Footer */}
- Tổng cộng
+ {t("trip.costTable.totalLabel")}
{tongCong.toLocaleString()}
@@ -112,7 +116,9 @@ const TripCostTable: React.FC = () => {
style={styles.viewDetailButton}
onPress={handleViewDetail}
>
- Xem chi tiết
+
+ {t("trip.costTable.viewDetail")}
+
)}
@@ -121,9 +127,11 @@ const TripCostTable: React.FC = () => {
{/* Header */}
- Loại
+ {t("trip.costTable.typeHeader")}
+
+
+ {t("trip.costTable.totalCostHeader")}
- Tổng chi phí
{/* Body */}
@@ -139,7 +147,7 @@ const TripCostTable: React.FC = () => {
{/* Footer */}
- Tổng cộng
+ {t("trip.costTable.totalLabel")}
{tongCong.toLocaleString()}
@@ -152,7 +160,9 @@ const TripCostTable: React.FC = () => {
style={styles.viewDetailButton}
onPress={handleViewDetail}
>
- Xem chi tiết
+
+ {t("trip.costTable.viewDetail")}
+
)}
diff --git a/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
index 77c063d..bab20f3 100644
--- a/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
+++ b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
@@ -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;
@@ -78,6 +74,7 @@ const CreateOrUpdateHaulModal: React.FC = ({
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 = ({
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 = ({
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 = ({
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 = ({
return (
- {fishName || "Chọn loài cá"}:
+ {fishName || t("trip.createHaulModal.selectFish")}:
{fishName ? `${quantity} ${unit}` : "---"}
@@ -335,7 +332,9 @@ const CreateOrUpdateHaulModal: React.FC = ({
name={`fish.${index}.id`}
render={({ field: { value, onChange } }) => (
- Tên cá
+
+ {t("trip.createHaulModal.fishName")}
+
({
label: fish.name,
@@ -343,12 +342,12 @@ const CreateOrUpdateHaulModal: React.FC = ({
}))}
value={value}
onChange={onChange}
- placeholder="Chọn loài cá"
+ placeholder={t("trip.createHaulModal.selectFish")}
disabled={!isEditing}
/>
{errors.fish?.[index]?.id && (
- {errors.fish[index]?.id?.message as string}
+ {t("trip.createHaulModal.selectFish")}
)}
@@ -363,7 +362,9 @@ const CreateOrUpdateHaulModal: React.FC = ({
name={`fish.${index}.quantity`}
render={({ field: { value, onChange, onBlur } }) => (
- Số lượng
+
+ {t("trip.createHaulModal.quantity")}
+
= ({
/>
{errors.fish?.[index]?.quantity && (
- {errors.fish[index]?.quantity?.message as string}
+ {t("trip.createHaulModal.quantity")}
)}
@@ -392,7 +393,9 @@ const CreateOrUpdateHaulModal: React.FC = ({
name={`fish.${index}.unit`}
render={({ field: { value, onChange } }) => (
- Đơn vị
+
+ {t("trip.createHaulModal.unit")}
+
({
label: unit.label,
@@ -400,13 +403,13 @@ const CreateOrUpdateHaulModal: React.FC = ({
}))}
value={value}
onChange={onChange}
- placeholder="Chọn đơn vị"
+ placeholder={t("trip.createHaulModal.unit")}
disabled={!isEditing}
listStyle={{ maxHeight: 100 }}
/>
{errors.fish?.[index]?.unit && (
- {errors.fish[index]?.unit?.message as string}
+ {t("trip.createHaulModal.unit")}
)}
@@ -423,7 +426,10 @@ const CreateOrUpdateHaulModal: React.FC = ({
name={`fish.${index}.size`}
render={({ field: { value, onChange, onBlur } }) => (
- Kích thước
+
+ {t("trip.createHaulModal.size")} (
+ {t("trip.createHaulModal.optional")})
+
= ({
/>
{errors.fish?.[index]?.size && (
- {errors.fish[index]?.size?.message as string}
+ {t("trip.createHaulModal.size")}
)}
@@ -452,12 +458,14 @@ const CreateOrUpdateHaulModal: React.FC = ({
name={`fish.${index}.sizeUnit`}
render={({ field: { value, onChange } }) => (
- Đơn vị
+
+ {t("trip.createHaulModal.unit")}
+
@@ -488,7 +496,9 @@ const CreateOrUpdateHaulModal: React.FC = ({
{/* Header */}
- {isCreateMode ? "Thêm mẻ cá" : "Chỉnh sửa mẻ cá"}
+ {isCreateMode
+ ? t("trip.createHaulModal.addFish")
+ : t("trip.createHaulModal.edit")}
{isEditing ? (
@@ -504,14 +514,18 @@ const CreateOrUpdateHaulModal: React.FC = ({
{ backgroundColor: "#6c757d" },
]}
>
- Hủy
+
+ {t("trip.createHaulModal.cancel")}
+
)}
- Lưu
+
+ {t("trip.createHaulModal.save")}
+
>
) : (
@@ -520,7 +534,9 @@ const CreateOrUpdateHaulModal: React.FC = ({
onPress={() => setIsEditing(true)}
style={[styles.saveButton, { backgroundColor: "#17a2b8" }]}
>
- Sửa
+
+ {t("trip.createHaulModal.edit")}
+
)
)}
@@ -546,14 +562,16 @@ const CreateOrUpdateHaulModal: React.FC = ({
onPress={() => append(defaultItem())}
style={styles.addButton}
>
- + Thêm loài cá
+
+ + {t("trip.createHaulModal.addFish")}
+
)}
{/* Error Message */}
{errors.fish && (
- {(errors.fish as any)?.message}
+ {t("trip.createHaulModal.validationError")}
)}
diff --git a/components/tripInfo/modal/CrewDetailModal.tsx b/components/tripInfo/modal/CrewDetailModal.tsx
index 3d50b34..6ed2e39 100644
--- a/components/tripInfo/modal/CrewDetailModal.tsx
+++ b/components/tripInfo/modal/CrewDetailModal.tsx
@@ -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 = ({
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 = ({
{/* Header */}
- Thông tin thuyền viên
+ {t("trip.crewDetailModal.title")}
diff --git a/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx b/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx
index 1074ef0..a64101f 100644
--- a/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx
+++ b/components/tripInfo/modal/NetDetailModal/components/InfoSection.tsx
@@ -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 = ({
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()
diff --git a/components/tripInfo/modal/TripCostDetailModal.tsx b/components/tripInfo/modal/TripCostDetailModal.tsx
index 1fd5431..6b40177 100644
--- a/components/tripInfo/modal/TripCostDetailModal.tsx
+++ b/components/tripInfo/modal/TripCostDetailModal.tsx
@@ -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 = ({
onClose,
data,
}) => {
+ const { t } = useI18n();
const [isEditing, setIsEditing] = useState(false);
const [editableData, setEditableData] = useState(data);
@@ -94,7 +96,7 @@ const TripCostDetailModal: React.FC = ({
{/* Header */}
- Chi tiết chi phí chuyến đi
+ {t("trip.costDetailModal.title")}
{isEditing ? (
<>
@@ -102,13 +104,17 @@ const TripCostDetailModal: React.FC = ({
onPress={handleCancel}
style={styles.cancelButton}
>
- Hủy
+
+ {t("trip.costDetailModal.cancel")}
+
- Lưu
+
+ {t("trip.costDetailModal.save")}
+
>
) : (
@@ -140,13 +146,15 @@ const TripCostDetailModal: React.FC = ({
{/* Loại */}
- Loại chi phí
+
+ {t("trip.costDetailModal.costType")}
+
updateItem(index, "type", value)}
editable={isEditing}
- placeholder="Nhập loại chi phí"
+ placeholder={t("trip.costDetailModal.enterCostType")}
/>
@@ -155,7 +163,9 @@ const TripCostDetailModal: React.FC = ({
- Số lượng
+
+ {t("trip.costDetailModal.quantity")}
+
= ({
/>
- Đơn vị
+
+ {t("trip.costDetailModal.unit")}
+
updateItem(index, "unit", value)}
editable={isEditing}
- placeholder="kg, lít..."
+ placeholder={t("trip.costDetailModal.placeholder")}
/>
{/* Chi phí/đơn vị */}
- Chi phí/đơn vị (VNĐ)
+
+ {t("trip.costDetailModal.costPerUnit")}
+
= ({
{/* Tổng chi phí */}
- Tổng chi phí
+
+ {t("trip.costDetailModal.totalCost")}
+
- {item.total_cost.toLocaleString()} VNĐ
+ {item.total_cost.toLocaleString()}{" "}
+ {t("trip.costDetailModal.vnd")}
@@ -208,9 +225,11 @@ const TripCostDetailModal: React.FC = ({
{/* Footer Total */}
- Tổng cộng
+
+ {t("trip.costDetailModal.total")}
+
- {tongCong.toLocaleString()} VNĐ
+ {tongCong.toLocaleString()} {t("trip.costDetailModal.vnd")}
diff --git a/components/ui/slice-switch.tsx b/components/ui/slice-switch.tsx
new file mode 100644
index 0000000..86da13b
--- /dev/null
+++ b/components/ui/slice-switch.tsx
@@ -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["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;
+ 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(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/config/localization.ts b/config/localization.ts
new file mode 100644
index 0000000..619546f
--- /dev/null
+++ b/config/localization.ts
@@ -0,0 +1,2 @@
+export { useI18n } from "@/hooks/use-i18n";
+export { default as i18n } from "./localization/i18n";
diff --git a/config/localization/i18n.ts b/config/localization/i18n.ts
new file mode 100644
index 0000000..567fce8
--- /dev/null
+++ b/config/localization/i18n.ts
@@ -0,0 +1,27 @@
+import en from "@/locales/en.json";
+import vi from "@/locales/vi.json";
+import { getLocales } from "expo-localization";
+import { I18n } from "i18n-js";
+
+// Set the key-value pairs for the different languages you want to support
+const translations = {
+ en,
+ vi,
+};
+
+const i18n = new I18n(translations);
+
+// Set the locale once at the beginning of your app
+// This will be set from storage in the useI18n hook, default to device language or 'en'
+i18n.locale = getLocales()[0].languageCode ?? "vi";
+
+// Enable fallback mechanism - if a key is missing in the current language, it will use the key from English
+i18n.enableFallback = true;
+
+// Set default locale to English if no locale is available
+i18n.defaultLocale = "vi";
+
+// Storage key for locale preference
+export const LOCALE_STORAGE_KEY = "app_locale_preference";
+
+export default i18n;
diff --git a/hooks/use-i18n.ts b/hooks/use-i18n.ts
new file mode 100644
index 0000000..92a39d6
--- /dev/null
+++ b/hooks/use-i18n.ts
@@ -0,0 +1,119 @@
+import i18n, { LOCALE_STORAGE_KEY } from "@/config/localization/i18n";
+import { getStorageItem, setStorageItem } from "@/utils/storage";
+import { getLocales } from "expo-localization";
+import {
+ PropsWithChildren,
+ createContext,
+ createElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+
+type SupportedLocale = "en" | "vi";
+
+type I18nContextValue = {
+ t: typeof i18n.t;
+ locale: SupportedLocale;
+ setLocale: (locale: SupportedLocale) => Promise;
+ isLoaded: boolean;
+};
+
+const I18nContext = createContext(undefined);
+
+const SUPPORTED_LOCALES: SupportedLocale[] = ["en", "vi"];
+
+const resolveSupportedLocale = (
+ locale: string | null | undefined
+): SupportedLocale => {
+ if (!locale) {
+ return "en";
+ }
+
+ const normalized = locale.split("-")[0]?.toLowerCase() as SupportedLocale;
+ if (normalized && SUPPORTED_LOCALES.includes(normalized)) {
+ return normalized;
+ }
+
+ return "en";
+};
+
+export const I18nProvider = ({ children }: PropsWithChildren) => {
+ const [locale, setLocaleState] = useState(
+ resolveSupportedLocale(i18n.locale)
+ );
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ useEffect(() => {
+ const loadLocale = async () => {
+ try {
+ const savedLocale = await getStorageItem(LOCALE_STORAGE_KEY);
+ const deviceLocale = getLocales()[0]?.languageCode;
+ const localeToUse = resolveSupportedLocale(savedLocale ?? deviceLocale);
+
+ if (localeToUse !== i18n.locale) {
+ i18n.locale = localeToUse;
+ }
+ setLocaleState(localeToUse);
+ } catch (error) {
+ console.error("Error loading locale preference:", error);
+ } finally {
+ setIsLoaded(true);
+ }
+ };
+
+ void loadLocale();
+ }, []);
+
+ const updateLocale = useCallback((nextLocale: SupportedLocale) => {
+ if (i18n.locale !== nextLocale) {
+ i18n.locale = nextLocale;
+ }
+ setLocaleState(nextLocale);
+ }, []);
+
+ const setLocale = useCallback(
+ async (nextLocale: SupportedLocale) => {
+ if (!SUPPORTED_LOCALES.includes(nextLocale)) {
+ console.warn(`Unsupported locale: ${nextLocale}`);
+ return;
+ }
+
+ try {
+ updateLocale(nextLocale);
+ await setStorageItem(LOCALE_STORAGE_KEY, nextLocale);
+ } catch (error) {
+ console.error("Error setting locale:", error);
+ }
+ },
+ [updateLocale]
+ );
+
+ const translate = useCallback(
+ (...args: Parameters) => i18n.t(...args),
+ [locale]
+ );
+
+ const value = useMemo(
+ () => ({
+ t: translate,
+ locale,
+ setLocale,
+ isLoaded,
+ }),
+ [locale, setLocale, translate, isLoaded]
+ );
+
+ return createElement(I18nContext.Provider, { value }, children);
+};
+
+export const useI18n = () => {
+ const context = useContext(I18nContext);
+ if (!context) {
+ throw new Error("useI18n must be used within an I18nProvider");
+ }
+
+ return context;
+};
diff --git a/locales/en.json b/locales/en.json
new file mode 100644
index 0000000..7575ae7
--- /dev/null
+++ b/locales/en.json
@@ -0,0 +1,197 @@
+{
+ "common": {
+ "app_name": "Sea Gateway",
+ "footer_text": "Product of Mobifone v1.0",
+ "ok": "OK",
+ "cancel": "Cancel",
+ "save": "Save",
+ "delete": "Delete",
+ "edit": "Edit",
+ "add": "Add",
+ "close": "Close",
+ "back": "Back",
+ "next": "Next",
+ "previous": "Previous",
+ "loading": "Loading...",
+ "error": "Error",
+ "success": "Success",
+ "warning": "Warning",
+ "language": "Language",
+ "language_vi": "Vietnamese",
+ "language_en": "English"
+ },
+ "navigation": {
+ "home": "Monitor",
+ "diary": "Diary",
+ "sensor": "Sensor",
+ "trip": "Trip",
+ "setting": "Settings"
+ },
+ "home": {
+ "welcome": "Welcome",
+ "noData": "No data available",
+ "gpsInfo": "GPS Information",
+ "tripActive": "Active Trip",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "speed": "Speed",
+ "heading": "Heading",
+ "offline": "Offline",
+ "online": "Online",
+ "sos": {
+ "title": "Emergency Alert",
+ "active": "In Emergency State",
+ "inactive": "Emergency",
+ "description": "Emergency Notification",
+ "content": "Content:",
+ "selectReason": "Select reason",
+ "statusInput": "Enter status",
+ "enterStatus": "Describe emergency status",
+ "confirm": "Confirm",
+ "cancel": "Cancel",
+ "statusRequired": "Please enter status",
+ "sendError": "Unable to send SOS signal"
+ }
+ },
+ "trip": {
+ "createNewTrip": "Create New Trip",
+ "endTrip": "End Trip",
+ "cancelTrip": "Cancel Trip",
+ "tripStatus": "Trip Status",
+ "tripDuration": "Trip Duration",
+ "distance": "Distance",
+ "speed": "Speed",
+ "startTime": "Start Time",
+ "endTime": "End Time",
+ "startTrip": "Start Trip",
+ "endHaul": "End Haul",
+ "startHaul": "Start Haul",
+ "endHaulConfirm": "Are you sure you want to end this haul?",
+ "endHaulTitle": "End Haul",
+ "startHaulConfirm": "Do you want to start a new haul?",
+ "startHaulTitle": "Start Haul",
+ "cancelButton": "Cancel",
+ "endButton": "End",
+ "startButton": "Start",
+ "successTitle": "Success",
+ "endHaulSuccess": "Haul ended successfully!",
+ "startHaulSuccess": "New haul started successfully!",
+ "startTripSuccess": "Trip started successfully!",
+ "alreadyStarted": "Trip has already been started or completed.",
+ "finishCurrentHaul": "Please finish the current haul before starting a new one",
+ "createHaulFailed": "Failed to create new haul!",
+ "weatherDescription": "Clear",
+ "costTable": {
+ "title": "Trip Cost",
+ "typeHeader": "Type",
+ "totalCostHeader": "Total Cost",
+ "totalLabel": "Total",
+ "viewDetail": "View Details"
+ },
+ "fishingTools": {
+ "title": "Fishing Tools List",
+ "nameHeader": "Name",
+ "quantityHeader": "Quantity",
+ "totalLabel": "Total"
+ },
+ "crewList": {
+ "title": "Crew List",
+ "nameHeader": "Name",
+ "roleHeader": "Role",
+ "totalLabel": "Total"
+ },
+ "netList": {
+ "title": "Haul List",
+ "sttHeader": "No.",
+ "statusHeader": "Status",
+ "completed": "Completed",
+ "pending": "Pending",
+ "haulPrefix": "Haul"
+ },
+ "createHaulModal": {
+ "title": "Catch Information",
+ "addSuccess": "Catch added successfully",
+ "addError": "Failed to add catch",
+ "updateSuccess": "Catch updated successfully",
+ "updateError": "Failed to update catch",
+ "fishName": "Fish Name",
+ "selectFish": "Select fish species",
+ "quantity": "Quantity",
+ "unit": "Unit",
+ "size": "Size",
+ "optional": "Optional",
+ "addFish": "Add Fish",
+ "save": "Save",
+ "cancel": "Cancel",
+ "edit": "Edit",
+ "done": "Done",
+ "fishListNotReady": "Fish species list not ready",
+ "gpsError": "Unable to get current GPS data",
+ "validationError": "Please add at least 1 fish species"
+ },
+ "crewDetailModal": {
+ "title": "Crew Information",
+ "personalId": "Personal ID",
+ "fullName": "Full Name",
+ "role": "Role",
+ "birthDate": "Date of Birth",
+ "phone": "Phone",
+ "address": "Address",
+ "joinedDate": "Joined Date",
+ "note": "Note",
+ "status": "Status",
+ "working": "Working",
+ "resigned": "Resigned",
+ "notUpdated": "Not updated"
+ },
+ "costDetailModal": {
+ "title": "Trip Cost Details",
+ "costType": "Cost Type",
+ "quantity": "Quantity",
+ "unit": "Unit",
+ "costPerUnit": "Cost Per Unit (VND)",
+ "totalCost": "Total Cost",
+ "total": "Total",
+ "edit": "Edit",
+ "save": "Save",
+ "cancel": "Cancel",
+ "enterCostType": "Enter cost type",
+ "placeholder": "e.g. kg, liters",
+ "vnd": "VND"
+ },
+ "buttonEndTrip": {
+ "title": "End",
+ "endTrip": "End Trip"
+ },
+ "buttonCancelTrip": {
+ "title": "Cancel Trip"
+ },
+ "infoSection": {
+ "sttLabel": "No.",
+ "haulPrefix": "Haul",
+ "statusLabel": "Status",
+ "statusCompleted": "Completed",
+ "statusPending": "Pending",
+ "startTimeLabel": "Start Time",
+ "endTimeLabel": "End Time",
+ "notUpdated": "Not updated"
+ }
+ },
+ "alarm": {
+ "title": "Alarm",
+ "noAlarm": "No alarm",
+ "warning": "Warning",
+ "danger": "Danger",
+ "critical": "Critical"
+ },
+ "auth": {
+ "login": "Login",
+ "logout": "Logout",
+ "username": "Username",
+ "username_placeholder": "Enter username",
+ "password": "Password",
+ "password_placeholder": "Enter password",
+ "loginError": "Login failed. Please try again.",
+ "sessionExpired": "Your session has expired. Please login again."
+ }
+}
diff --git a/locales/vi.json b/locales/vi.json
new file mode 100644
index 0000000..96d3a73
--- /dev/null
+++ b/locales/vi.json
@@ -0,0 +1,198 @@
+{
+ "common": {
+ "app_name": "Hệ thống giám sát tàu cá",
+ "footer_text": "Sản phẩm của Mobifone v1.0",
+ "ok": "OK",
+ "cancel": "Hủy",
+ "save": "Lưu",
+ "delete": "Xóa",
+ "edit": "Chỉnh sửa",
+ "add": "Thêm",
+ "close": "Đóng",
+ "back": "Quay lại",
+ "next": "Tiếp theo",
+ "previous": "Quay lại",
+ "loading": "Đang tải...",
+ "error": "Lỗi",
+ "success": "Thành công",
+ "warning": "Cảnh báo",
+ "language": "Ngôn ngữ",
+ "language_vi": "Tiếng Việt",
+ "language_en": "Tiếng Anh"
+ },
+ "navigation": {
+ "home": "Giám sát",
+ "diary": "Nhật ký",
+ "sensor": "Cảm biến",
+ "trip": "Chuyến đi",
+ "setting": "Cài đặt"
+ },
+ "home": {
+ "welcome": "Chào mừng",
+ "noData": "Không có dữ liệu",
+ "gpsInfo": "Thông tin GPS",
+ "tripActive": "Chuyến hoạt động",
+ "latitude": "Vĩ độ",
+ "longitude": "Kinh độ",
+ "speed": "Tốc độ",
+ "heading": "Hướng",
+ "offline": "Ngoại tuyến",
+ "online": "Trực tuyến",
+ "sos": {
+ "title": "Thông báo khẩn cấp",
+ "active": "Đang trong trạng thái khẩn cấp",
+ "inactive": "Khẩn cấp",
+ "description": "Thông báo khẩn cấp",
+ "content": "Nội dung:",
+ "selectReason": "Chọn lý do",
+ "statusInput": "Nhập trạng thái",
+ "enterStatus": "Mô tả trạng thái khẩn cấp",
+ "confirm": "Xác nhận",
+ "cancel": "Hủy",
+ "statusRequired": "Vui lòng nhập trạng thái",
+ "sendError": "Không thể gửi tín hiệu SOS"
+ }
+ },
+ "trip": {
+ "createNewTrip": "Tạo chuyến mới",
+ "endTrip": "Kết thúc chuyến",
+ "cancelTrip": "Hủy chuyến",
+ "tripStatus": "Trạng thái chuyến",
+ "tripDuration": "Thời lượng chuyến",
+ "distance": "Khoảng cách",
+ "speed": "Tốc độ",
+ "startTime": "Thời gian bắt đầu",
+ "endTime": "Thời gian kết thúc",
+ "startTrip": "Bắt đầu chuyến đi",
+ "endHaul": "Kết thúc mẻ lưới",
+ "startHaul": "Bắt đầu mẻ lưới",
+ "endHaulConfirm": "Bạn có chắc chắn muốn kết thúc mẻ lưới này?",
+ "endHaulTitle": "Kết thúc mẻ lưới",
+ "startHaulConfirm": "Bạn có muốn bắt đầu mẻ lưới mới?",
+ "startHaulTitle": "Bắt đầu mẻ lưới",
+ "cancelButton": "Hủy",
+ "endButton": "Kết thúc",
+ "startButton": "Bắt đầu",
+ "successTitle": "Thành công",
+ "endHaulSuccess": "Đã kết thúc mẻ lưới!",
+ "startHaulSuccess": "Đã bắt đầu mẻ lưới mới!",
+ "startTripSuccess": "Bắt đầu chuyến đi thành công!",
+ "alreadyStarted": "Chuyến đi đã được bắt đầu hoặc hoàn thành.",
+ "finishCurrentHaul": "Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới",
+ "createHaulFailed": "Tạo mẻ lưới mới thất bại!",
+ "weatherDescription": "Nắng đẹp",
+ "costTable": {
+ "title": "Chi phí chuyến đi",
+ "typeHeader": "Loại",
+ "totalCostHeader": "Tổng chi phí",
+ "totalLabel": "Tổng cộng",
+ "viewDetail": "Xem chi tiết"
+ },
+ "fishingTools": {
+ "title": "Danh sách ngư cụ",
+ "nameHeader": "Tên",
+ "quantityHeader": "Số lượng",
+ "totalLabel": "Tổng cộng"
+ },
+ "crewList": {
+ "title": "Danh sách thuyền viên",
+ "nameHeader": "Tên",
+ "roleHeader": "Chức vụ",
+ "totalLabel": "Tổng cộng"
+ },
+ "netList": {
+ "title": "Danh sách mẻ lưới",
+ "sttHeader": "STT",
+ "statusHeader": "Trạng thái",
+ "completed": "Đã hoàn thành",
+ "pending": "Chưa hoàn thành",
+ "haulPrefix": "Mẻ"
+ },
+ "createHaulModal": {
+ "title": "Thông tin mẻ cá",
+ "addSuccess": "Thêm mẻ cá thành công",
+ "addError": "Thêm mẻ cá thất bại",
+ "updateSuccess": "Cập nhật mẻ cá thành công",
+ "updateError": "Cập nhật mẻ cá thất bại",
+ "fishName": "Tên cá",
+ "selectFish": "Chọn loài cá",
+ "quantity": "Số lượng",
+ "unit": "Đơn vị",
+ "size": "Kích thước",
+ "optional": "Không bắt buộc",
+ "addFish": "Thêm cá",
+ "save": "Lưu",
+ "cancel": "Hủy",
+ "edit": "Chỉnh sửa",
+ "done": "Xong",
+ "fishListNotReady": "Danh sách loài cá chưa sẵn sàng",
+ "gpsError": "Không thể lấy dữ liệu GPS hiện tại",
+ "validationError": "Vui lòng thêm ít nhất 1 loài cá"
+ },
+ "crewDetailModal": {
+ "title": "Thông tin thuyền viên",
+ "personalId": "Mã định danh",
+ "fullName": "Họ và tên",
+ "role": "Chức vụ",
+ "birthDate": "Ngày sinh",
+ "phone": "Số điện thoại",
+ "address": "Địa chỉ",
+ "joinedDate": "Ngày vào làm",
+ "note": "Ghi chú",
+ "status": "Tình trạng",
+ "working": "Đang làm việc",
+ "resigned": "Đã nghỉ",
+ "notUpdated": "Chưa cập nhật"
+ },
+ "costDetailModal": {
+ "title": "Chi tiết chi phí chuyến đi",
+ "costType": "Loại chi phí",
+ "quantity": "Số lượng",
+ "unit": "Đơn vị",
+ "costPerUnit": "Chi phí/đơn vị (VNĐ)",
+ "totalCost": "Tổng chi phí",
+ "total": "Tổng cộng",
+ "edit": "Chỉnh sửa",
+ "save": "Lưu",
+ "cancel": "Hủy",
+ "enterCostType": "Nhập loại chi phí",
+ "placeholder": "ví dụ: kg, lít",
+ "vnd": "VNĐ"
+ },
+ "buttonEndTrip": {
+ "title": "Kết thúc",
+ "endTrip": "Kết thúc chuyến"
+ },
+ "buttonCancelTrip": {
+ "title": "Hủy chuyến đi"
+ },
+ "infoSection": {
+ "sttLabel": "Số thứ tự",
+ "haulPrefix": "Mẻ",
+ "statusLabel": "Trạng thái",
+ "statusCompleted": "Đã hoàn thành",
+ "statusPending": "Chưa hoàn thành",
+ "startTimeLabel": "Thời gian bắt đầu",
+ "endTimeLabel": "Thời gian kết thúc",
+ "notUpdated": "Chưa cập nhật"
+ }
+ },
+ "alarm": {
+ "title": "Cảnh báo",
+ "noAlarm": "Không có cảnh báo",
+ "warning": "Cảnh báo",
+ "danger": "Nguy hiểm",
+ "critical": "Rất nguy hiểm"
+ },
+ "auth": {
+ "login": "Đăng nhập",
+ "logout": "Đăng xuất",
+ "username": "Tài khoản",
+ "username_placeholder": "Nhập tài khoản",
+
+ "password": "Mật khẩu",
+ "password_placeholder": "Nhập mật khẩu",
+ "loginError": "Đăng nhập thất bại. Vui lòng thử lại.",
+ "sessionExpired": "Phiên của bạn đã hết hạn. Vui lòng đăng nhập lại."
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 7a18e81..edecd7d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@gluestack-ui/core": "^3.0.12",
"@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2",
+ "@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
@@ -29,12 +30,14 @@
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8",
+ "expo-localization": "~17.0.7",
"expo-router": "~6.0.13",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8",
+ "i18n-js": "^4.5.1",
"nativewind": "^4.2.1",
"react": "19.1.0",
"react-aria": "^3.44.0",
@@ -2530,6 +2533,12 @@
"node": ">=12"
}
},
+ "node_modules/@islacel/react-native-custom-switch": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@islacel/react-native-custom-switch/-/react-native-custom-switch-1.0.10.tgz",
+ "integrity": "sha512-BnNXcnpbPK8C0FBdTL5BqVH+Y6iLYKO9bN7ElpuJD2P6u2zcyDm8VYrNLke/+ZDawFd+XOentu5Zx64fx6K25w==",
+ "license": "MIT"
+ },
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -6606,6 +6615,15 @@
"node": ">=0.6"
}
},
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -8592,6 +8610,19 @@
"react-native": "*"
}
},
+ "node_modules/expo-localization": {
+ "version": "17.0.7",
+ "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.7.tgz",
+ "integrity": "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA==",
+ "license": "MIT",
+ "dependencies": {
+ "rtl-detect": "^1.0.2"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*"
+ }
+ },
"node_modules/expo-modules-autolinking": {
"version": "3.0.19",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.19.tgz",
@@ -9977,6 +10008,17 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
+ "node_modules/i18n-js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/i18n-js/-/i18n-js-4.5.1.tgz",
+ "integrity": "sha512-n7jojFj1WC0tztgr0I8jqTXuIlY1xNzXnC3mjKX/YjJhimdM+jXM8vOmn9d3xQFNC6qDHJ4ovhdrGXrRXLIGkA==",
+ "license": "MIT",
+ "dependencies": {
+ "bignumber.js": "*",
+ "lodash": "*",
+ "make-plural": "*"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -11306,6 +11348,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -11429,6 +11477,12 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/make-plural": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.4.0.tgz",
+ "integrity": "sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg==",
+ "license": "Unicode-DFS-2016"
+ },
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -14335,6 +14389,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/rtl-detect": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
+ "integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
diff --git a/package.json b/package.json
index 61e3d14..431a61e 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@gluestack-ui/core": "^3.0.12",
"@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2",
+ "@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
@@ -32,12 +33,14 @@
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8",
+ "expo-localization": "~17.0.7",
"expo-router": "~6.0.13",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8",
+ "i18n-js": "^4.5.1",
"nativewind": "^4.2.1",
"react": "19.1.0",
"react-aria": "^3.44.0",