add en/vi language

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

224
LOCALIZATION.md Normal file
View File

@@ -0,0 +1,224 @@
# Localization Setup Guide
## Overview
Ứng dụng đã được cấu hình hỗ trợ 2 ngôn ngữ: **English (en)****Tiếng Việt (vi)** sử dụng `expo-localization``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 (
<View>
<Text>{t("common.ok")}</Text>
<Text>{t("navigation.home")}</Text>
<Text>Current locale: {locale}</Text>
</View>
);
}
```
### 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 <Text>{t("myFeature.title")}</Text>;
```
## Cách thay đổi ngôn ngữ
```tsx
const { setLocale } = useI18n();
// Thay đổi sang Tiếng Việt
<Button onPress={() => setLocale('vi')} title="Tiếng Việt" />
// Thay đổi sang English
<Button onPress={() => 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)

View File

@@ -45,6 +45,15 @@
{ {
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera" "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
} }
],
[
"expo-localization",
{
"supportedLocales": {
"ios": ["en", "vi"],
"android": ["en", "vi"]
}
}
] ]
], ],
"experiments": { "experiments": {

View File

@@ -4,14 +4,16 @@ import { HapticTab } from "@/components/haptic-tab";
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme"; import { useColorScheme } from "@/hooks/use-color-scheme";
import { useI18n } from "@/hooks/use-i18n";
import { startEvents, stopEvents } from "@/services/device_events"; import { startEvents, stopEvents } from "@/services/device_events";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const segments = useSegments(); const segments = useSegments() as string[];
const prev = useRef<string | null>(null); const prev = useRef<string | null>(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(() => { useEffect(() => {
if (prev.current !== currentSegment) { if (prev.current !== currentSegment) {
// console.log("Tab changed ->", { from: prev.current, to: currentSegment }); // console.log("Tab changed ->", { from: prev.current, to: currentSegment });
@@ -39,7 +41,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: "Giám sát", title: t("navigation.home"),
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="map.fill" color={color} /> <IconSymbol size={28} name="map.fill" color={color} />
), ),
@@ -49,7 +51,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="tripInfo" name="tripInfo"
options={{ options={{
title: "Chuyến Đi", title: t("navigation.trip"),
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="ferry.fill" color={color} /> <IconSymbol size={28} name="ferry.fill" color={color} />
), ),
@@ -58,7 +60,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="diary" name="diary"
options={{ options={{
title: "Nhật Ký", title: t("navigation.diary"),
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="book.closed.fill" color={color} /> <IconSymbol size={28} name="book.closed.fill" color={color} />
), ),
@@ -67,7 +69,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="sensor" name="sensor"
options={{ options={{
title: "Cảm biến", title: t("navigation.sensor"),
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<IconSymbol <IconSymbol
size={28} size={28}
@@ -80,7 +82,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="setting" name="setting"
options={{ options={{
title: "Cài đặt", title: t("navigation.setting"),
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="gear" color={color} /> <IconSymbol size={28} name="gear" color={color} />
), ),

View File

@@ -91,7 +91,7 @@ export default function HomeScreen() {
if (TrackPointsData && TrackPointsData.length > 0) { if (TrackPointsData && TrackPointsData.length > 0) {
setTrackPointsData(TrackPointsData); setTrackPointsData(TrackPointsData);
} else { } else {
setTrackPointsData(null); setTrackPointsData([]);
} }
}; };

View File

@@ -1,13 +1,15 @@
import { useRouter } from "expo-router"; 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 { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view"; import { ThemedView } from "@/components/themed-view";
import { api } from "@/config";
import { DOMAIN, TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { useI18n } from "@/hooks/use-i18n";
import { removeStorageItem } from "@/utils/storage"; import { removeStorageItem } from "@/utils/storage";
import { useState } from "react";
type Todo = { type Todo = {
userId: number; userId: number;
id: number; id: number;
@@ -18,23 +20,40 @@ type Todo = {
export default function SettingScreen() { export default function SettingScreen() {
const router = useRouter(); const router = useRouter();
const [data, setData] = useState<Todo | null>(null); const [data, setData] = useState<Todo | null>(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(() => { const toggleSwitch = async () => {
// getData(); const newLocale = isEnabled ? "en" : "vi";
// }, []); await setLocale(newLocale);
const getData = async () => {
try {
const response = await api.get("/todos/1");
setData(response.data);
} catch (error) {
console.error("Error fetching data:", error);
}
}; };
return ( return (
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type="title">Settings</ThemedText> <ThemedText type="title">{t("navigation.setting")}</ThemedText>
<View style={styles.settingItem}>
<ThemedText type="default">{t("common.language")}</ThemedText>
{/* <Switch
trackColor={{ false: "#767577", true: "#81b0ff" }}
thumbColor={isEnabled ? "#f5dd4b" : "#f4f3f4"}
ios_backgroundColor="#3e3e3e"
onValueChange={toggleSwitch}
value={isEnabled}
/> */}
<RotateSwitch
initialValue={isEnabled}
onChange={toggleSwitch}
size="sm"
offImage={EnIcon}
onImage={VnIcon}
/>
</View>
<ThemedView <ThemedView
style={styles.button} style={styles.button}
onTouchEnd={async () => { onTouchEnd={async () => {
@@ -43,8 +62,9 @@ export default function SettingScreen() {
router.navigate("/auth/login"); router.navigate("/auth/login");
}} }}
> >
<ThemedText type="defaultSemiBold">Đăng xuất</ThemedText> <ThemedText type="defaultSemiBold">{t("auth.logout")}</ThemedText>
</ThemedView> </ThemedView>
{data && ( {data && (
<ThemedView style={{ marginTop: 20 }}> <ThemedView style={{ marginTop: 20 }}>
<ThemedText type="default">{data.title}</ThemedText> <ThemedText type="default">{data.title}</ThemedText>
@@ -62,11 +82,23 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: 20, 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: { button: {
marginTop: 20, marginTop: 20,
padding: 10, paddingVertical: 12,
paddingHorizontal: 20,
backgroundColor: "#007AFF", backgroundColor: "#007AFF",
borderRadius: 5, borderRadius: 8,
}, },
}); });

View File

@@ -14,6 +14,7 @@ import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth"; import { setRouterInstance } from "@/config/auth";
import "@/global.css"; import "@/global.css";
import { useColorScheme } from "@/hooks/use-color-scheme"; import { useColorScheme } from "@/hooks/use-color-scheme";
import { I18nProvider } from "@/hooks/use-i18n";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import "../global.css"; import "../global.css";
export default function RootLayout() { export default function RootLayout() {
@@ -23,10 +24,12 @@ export default function RootLayout() {
useEffect(() => { useEffect(() => {
setRouterInstance(router); setRouterInstance(router);
}, [router]); }, [router]);
return ( return (
<I18nProvider>
<GluestackUIProvider> <GluestackUIProvider>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}> <ThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<Stack <Stack
screenOptions={{ headerShown: false }} screenOptions={{ headerShown: false }}
initialRouteName="auth/login" initialRouteName="auth/login"
@@ -56,5 +59,6 @@ export default function RootLayout() {
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} /> <Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</ThemeProvider> </ThemeProvider>
</GluestackUIProvider> </GluestackUIProvider>
</I18nProvider>
); );
} }

View File

@@ -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 ScanQRCode from "@/components/ScanQRCode";
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view"; import { ThemedView } from "@/components/themed-view";
import SliceSwitch from "@/components/ui/slice-switch";
import { DOMAIN, TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { login } from "@/controller/AuthController"; import { login } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n";
import { showErrorToast, showWarningToast } from "@/services/toast_service"; import { showErrorToast, showWarningToast } from "@/services/toast_service";
import { import {
getStorageItem, getStorageItem,
@@ -25,7 +30,6 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
export default function LoginScreen() { export default function LoginScreen() {
const router = useRouter(); const router = useRouter();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -33,6 +37,8 @@ export default function LoginScreen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isShowingQRScanner, setIsShowingQRScanner] = useState(false); const [isShowingQRScanner, setIsShowingQRScanner] = useState(false);
const { t, setLocale, locale } = useI18n();
const [isVNLang, setIsVNLang] = useState(false);
const checkLogin = useCallback(async () => { const checkLogin = useCallback(async () => {
const token = await getStorageItem(TOKEN); const token = await getStorageItem(TOKEN);
@@ -47,7 +53,6 @@ export default function LoginScreen() {
return; return;
} }
const parsed = parseJwtToken(token); const parsed = parseJwtToken(token);
// console.log("Parse Token: ", parsed);
const { exp } = parsed; const { exp } = parsed;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@@ -60,6 +65,10 @@ export default function LoginScreen() {
} }
}, [router]); }, [router]);
useEffect(() => {
setIsVNLang(locale === "vi");
}, [locale]);
useEffect(() => { useEffect(() => {
checkLogin(); checkLogin();
}, [checkLogin]); }, [checkLogin]);
@@ -131,6 +140,15 @@ export default function LoginScreen() {
} }
}; };
const handleSwitchLanguage = (isVN: boolean) => {
if (isVN) {
setLocale("vi");
} else {
setLocale("en");
}
setIsVNLang(isVN);
};
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : "height"}
@@ -147,10 +165,7 @@ export default function LoginScreen() {
resizeMode="contain" resizeMode="contain"
/> />
<ThemedText type="title" style={styles.title}> <ThemedText type="title" style={styles.title}>
Hệ thống giám sát tàu {t("common.app_name")}
</ThemedText>
<ThemedText style={styles.subtitle}>
Đăng nhập đ tiếp tục
</ThemedText> </ThemedText>
</View> </View>
@@ -158,10 +173,10 @@ export default function LoginScreen() {
<View style={styles.formContainer}> <View style={styles.formContainer}>
{/* Username Input */} {/* Username Input */}
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<ThemedText style={styles.label}>Tài khoản</ThemedText> <ThemedText style={styles.label}>{t("auth.username")}</ThemedText>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Nhập tài khoản" placeholder={t("auth.username_placeholder")}
placeholderTextColor="#999" placeholderTextColor="#999"
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
@@ -172,11 +187,11 @@ export default function LoginScreen() {
{/* Password Input */} {/* Password Input */}
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<ThemedText style={styles.label}>Mật khẩu</ThemedText> <ThemedText style={styles.label}>{t("auth.password")}</ThemedText>
<View className="relative"> <View className="relative">
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Nhập mật khẩu" placeholder={t("auth.password_placeholder")}
placeholderTextColor="#999" placeholderTextColor="#999"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
@@ -228,7 +243,7 @@ export default function LoginScreen() {
{loading ? ( {loading ? (
<ActivityIndicator color="#fff" size="small" /> <ActivityIndicator color="#fff" size="small" />
) : ( ) : (
<Text style={styles.loginButtonText}>Đăng nhập</Text> <Text style={styles.loginButtonText}>{t("auth.login")}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -255,18 +270,32 @@ export default function LoginScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Footer text */} {/* Language Switcher */}
<View style={styles.footerContainer}> <View style={styles.languageSwitcherContainer}>
<ThemedText style={styles.footerText}> <RotateSwitch
Chưa tài khoản?{" "} initialValue={isVNLang}
<Text style={styles.linkText}>Đăng ngay</Text> onChange={handleSwitchLanguage}
</ThemedText> size="sm"
offImage={EnIcon}
onImage={VnIcon}
/>
<SliceSwitch
size="sm"
leftIcon="moon"
leftIconColor="white"
rightIcon="sunny"
rightIconColor="orange"
activeBackgroundColor="black"
inactiveBackgroundColor="white"
inactiveOverlayColor="black"
activeOverlayColor="white"
/>
</View> </View>
{/* Copyright */} {/* Copyright */}
<View style={styles.copyrightContainer}> <View style={styles.copyrightContainer}>
<ThemedText style={styles.copyrightText}> <ThemedText style={styles.copyrightText}>
© {new Date().getFullYear()} - Sản phẩm của Mobifone © {new Date().getFullYear()} - {t("common.footer_text")}
</ThemedText> </ThemedText>
</View> </View>
</View> </View>
@@ -364,4 +393,46 @@ const styles = StyleSheet.create({
opacity: 0.6, opacity: 0.6,
textAlign: "center", 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",
},
}); });

BIN
assets/icons/en_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/icons/vi_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n";
import React from "react"; import React from "react";
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/CrewDetailModal.styles"; import styles from "./style/CrewDetailModal.styles";
@@ -21,30 +22,46 @@ const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
onClose, onClose,
crewData, crewData,
}) => { }) => {
const { t } = useI18n();
if (!crewData) return null; if (!crewData) return null;
const infoItems = [ 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 value: crewData.Person.birth_date
? new Date(crewData.Person.birth_date).toLocaleDateString() ? 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 value: crewData.joined_at
? new Date(crewData.joined_at).toLocaleDateString() ? 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", label: t("trip.crewDetailModal.note"),
value: crewData.left_at ? "Đã nghỉ" : "Đang làm việc", 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}> <View style={styles.container}>
{/* Header */} {/* Header */}
<View style={styles.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}> <TouchableOpacity onPress={onClose} style={styles.closeButton}>
<View style={styles.closeIconButton}> <View style={styles.closeIconButton}>
<IconSymbol name="xmark" size={28} color="#fff" /> <IconSymbol name="xmark" size={28} color="#fff" />

View File

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

View File

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

View File

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

2
config/localization.ts Normal file
View File

@@ -0,0 +1,2 @@
export { useI18n } from "@/hooks/use-i18n";
export { default as i18n } from "./localization/i18n";

View File

@@ -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;

119
hooks/use-i18n.ts Normal file
View File

@@ -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<void>;
isLoaded: boolean;
};
const I18nContext = createContext<I18nContextValue | undefined>(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<unknown>) => {
const [locale, setLocaleState] = useState<SupportedLocale>(
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<typeof i18n.t>) => i18n.t(...args),
[locale]
);
const value = useMemo<I18nContextValue>(
() => ({
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;
};

197
locales/en.json Normal file
View File

@@ -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."
}
}

198
locales/vi.json Normal file
View File

@@ -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."
}
}

60
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@gluestack-ui/core": "^3.0.12", "@gluestack-ui/core": "^3.0.12",
"@gluestack-ui/utils": "^3.0.11", "@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3", "@legendapp/motion": "^2.5.3",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
@@ -29,12 +30,14 @@
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8", "expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"i18n-js": "^4.5.1",
"nativewind": "^4.2.1", "nativewind": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",
"react-aria": "^3.44.0", "react-aria": "^3.44.0",
@@ -2530,6 +2533,12 @@
"node": ">=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": { "node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -6606,6 +6615,15 @@
"node": ">=0.6" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -8592,6 +8610,19 @@
"react-native": "*" "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": { "node_modules/expo-modules-autolinking": {
"version": "3.0.19", "version": "3.0.19",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.19.tgz", "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==", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -11306,6 +11348,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -11429,6 +11477,12 @@
"yallist": "^3.0.2" "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": { "node_modules/makeerror": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -14335,6 +14389,12 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",

View File

@@ -16,6 +16,7 @@
"@gluestack-ui/core": "^3.0.12", "@gluestack-ui/core": "^3.0.12",
"@gluestack-ui/utils": "^3.0.11", "@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3", "@legendapp/motion": "^2.5.3",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
@@ -32,12 +33,14 @@
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8", "expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"i18n-js": "^4.5.1",
"nativewind": "^4.2.1", "nativewind": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",
"react-aria": "^3.44.0", "react-aria": "^3.44.0",