add en/vi language
This commit is contained in:
224
LOCALIZATION.md
Normal file
224
LOCALIZATION.md
Normal 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)** và **Tiếng Việt (vi)** sử dụng `expo-localization` và `i18n-js`.
|
||||||
|
|
||||||
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
|
```
|
||||||
|
/config
|
||||||
|
/localization
|
||||||
|
- i18n.ts # Cấu hình i18n (khởi tạo locale, enable fallback, etc.)
|
||||||
|
- localization.ts # Export main exports
|
||||||
|
|
||||||
|
/locales
|
||||||
|
- en.json # Các string tiếng Anh
|
||||||
|
- vi.json # Các string tiếng Việt
|
||||||
|
|
||||||
|
/hooks
|
||||||
|
- use-i18n.ts # Hook để sử dụng i18n trong components
|
||||||
|
|
||||||
|
/state
|
||||||
|
- use-locale-store.ts # Zustand store cho global locale state management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách sử dụng
|
||||||
|
|
||||||
|
### 1. Import i18n hook trong component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
|
||||||
|
export default function MyComponent() {
|
||||||
|
const { t, locale, setLocale } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)
|
||||||
9
app.json
9
app.json
@@ -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": {
|
||||||
|
|||||||
@@ -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} />
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 cá
|
{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 có tài khoản?{" "}
|
initialValue={isVNLang}
|
||||||
<Text style={styles.linkText}>Đăng ký 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
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
BIN
assets/icons/vi_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
307
components/rotate-switch.tsx
Normal file
307
components/rotate-switch.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Image,
|
||||||
|
ImageSourcePropType,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||||
|
|
||||||
|
const SIZE_PRESETS = {
|
||||||
|
sm: { width: 64, height: 32 },
|
||||||
|
md: { width: 80, height: 40 },
|
||||||
|
lg: { width: 96, height: 48 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SwitchSize = keyof typeof SIZE_PRESETS;
|
||||||
|
|
||||||
|
const DEFAULT_TOGGLE_DURATION = 400;
|
||||||
|
const DEFAULT_OFF_IMAGE =
|
||||||
|
"https://images.vexels.com/media/users/3/163966/isolated/preview/6ecbb5ec8c121c0699c9b9179d6b24aa-england-flag-language-icon-circle.png";
|
||||||
|
const DEFAULT_ON_IMAGE =
|
||||||
|
"https://cdn-icons-png.flaticon.com/512/197/197473.png";
|
||||||
|
const DEFAULT_INACTIVE_BG = "#D3DAD9";
|
||||||
|
const DEFAULT_ACTIVE_BG = "#C2E2FA";
|
||||||
|
const PRESSED_SCALE = 0.96;
|
||||||
|
const PRESS_FEEDBACK_DURATION = 120;
|
||||||
|
|
||||||
|
type RotateSwitchProps = {
|
||||||
|
size?: SwitchSize;
|
||||||
|
onImage?: ImageSourcePropType | string;
|
||||||
|
offImage?: ImageSourcePropType | string;
|
||||||
|
initialValue?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
activeBackgroundColor?: string;
|
||||||
|
inactiveBackgroundColor?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toImageSource = (
|
||||||
|
input: ImageSourcePropType | string | undefined,
|
||||||
|
fallbackUri: string
|
||||||
|
): ImageSourcePropType => {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return { uri: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uri: fallbackUri };
|
||||||
|
};
|
||||||
|
|
||||||
|
const RotateSwitch = ({
|
||||||
|
size = "md",
|
||||||
|
onImage,
|
||||||
|
offImage,
|
||||||
|
duration,
|
||||||
|
activeBackgroundColor = DEFAULT_ACTIVE_BG,
|
||||||
|
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
|
||||||
|
initialValue = false,
|
||||||
|
style,
|
||||||
|
onChange,
|
||||||
|
}: RotateSwitchProps) => {
|
||||||
|
const { width: containerWidth, height: containerHeight } =
|
||||||
|
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||||
|
const knobSize = containerHeight;
|
||||||
|
const knobTravel = containerWidth - knobSize;
|
||||||
|
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||||
|
|
||||||
|
const resolvedOffImage = useMemo(
|
||||||
|
() => toImageSource(offImage, DEFAULT_OFF_IMAGE),
|
||||||
|
[offImage]
|
||||||
|
);
|
||||||
|
const resolvedOnImage = useMemo(
|
||||||
|
() => toImageSource(onImage, DEFAULT_ON_IMAGE),
|
||||||
|
[onImage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isOn, setIsOn] = useState(initialValue);
|
||||||
|
const [bgOn, setBgOn] = useState(initialValue);
|
||||||
|
const [displaySource, setDisplaySource] = useState<ImageSourcePropType>(
|
||||||
|
initialValue ? resolvedOnImage : resolvedOffImage
|
||||||
|
);
|
||||||
|
|
||||||
|
const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current;
|
||||||
|
const pressScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const listenerIdRef = useRef<string | number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplaySource(bgOn ? resolvedOnImage : resolvedOffImage);
|
||||||
|
}, [bgOn, resolvedOffImage, resolvedOnImage]);
|
||||||
|
|
||||||
|
const removeProgressListener = () => {
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachHalfwaySwapListener = (next: boolean) => {
|
||||||
|
removeProgressListener();
|
||||||
|
let swapped = false;
|
||||||
|
listenerIdRef.current = progress.addListener(({ value }) => {
|
||||||
|
if (swapped) return;
|
||||||
|
const crossedHalfway = next ? value >= 0.5 : value <= 0.5;
|
||||||
|
if (!crossedHalfway) return;
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
removeProgressListener();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up listener on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
removeProgressListener();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep internal state in sync when `initialValue` prop changes.
|
||||||
|
// Users may pass a changing `initialValue` (like from parent state) and
|
||||||
|
// expect the switch to reflect that. Animate `progress` toward the
|
||||||
|
// corresponding value and update images/background when done.
|
||||||
|
useEffect(() => {
|
||||||
|
// If no change, do nothing
|
||||||
|
if (initialValue === isOn) return;
|
||||||
|
|
||||||
|
const next = initialValue;
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
removeProgressListener();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update isOn immediately so accessibilityState etc. reflect change.
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
attachHalfwaySwapListener(next);
|
||||||
|
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
// Ensure final state reflects the target in case animation skips halfway listener.
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
initialValue,
|
||||||
|
isOn,
|
||||||
|
animationDuration,
|
||||||
|
progress,
|
||||||
|
resolvedOffImage,
|
||||||
|
resolvedOnImage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const knobTranslateX = progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, knobTravel],
|
||||||
|
});
|
||||||
|
|
||||||
|
const knobRotation = progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ["0deg", "180deg"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatePress = (toValue: number) => {
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
animatePress(PRESSED_SCALE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
animatePress(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const next = !isOn;
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
removeProgressListener();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
attachHalfwaySwapListener(next);
|
||||||
|
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggle}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityState={{ checked: isOn }}
|
||||||
|
style={[styles.pressable, style]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.shadowWrapper,
|
||||||
|
{
|
||||||
|
transform: [{ scale: pressScale }],
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
backgroundColor: bgOn
|
||||||
|
? activeBackgroundColor
|
||||||
|
: inactiveBackgroundColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<AnimatedImage
|
||||||
|
source={displaySource}
|
||||||
|
style={[
|
||||||
|
styles.knob,
|
||||||
|
{
|
||||||
|
width: knobSize,
|
||||||
|
height: knobSize,
|
||||||
|
borderRadius: knobSize / 2,
|
||||||
|
transform: [
|
||||||
|
{ translateX: knobTranslateX },
|
||||||
|
{ rotate: knobRotation },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pressable: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
shadowWrapper: {
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
knob: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RotateSwitch;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { 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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 cá</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 cá</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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
246
components/ui/slice-switch.tsx
Normal file
246
components/ui/slice-switch.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
OpaqueColorValue,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
const SIZE_PRESETS = {
|
||||||
|
sm: { width: 64, height: 32 },
|
||||||
|
md: { width: 80, height: 40 },
|
||||||
|
lg: { width: 96, height: 48 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SwitchSize = keyof typeof SIZE_PRESETS;
|
||||||
|
|
||||||
|
const DEFAULT_TOGGLE_DURATION = 400;
|
||||||
|
|
||||||
|
// Default both backgrounds to a grey tone when not provided
|
||||||
|
const DEFAULT_INACTIVE_BG = "#D3DAD9";
|
||||||
|
const DEFAULT_ACTIVE_BG = "#D3DAD9";
|
||||||
|
const PRESSED_SCALE = 0.96;
|
||||||
|
const PRESS_FEEDBACK_DURATION = 120;
|
||||||
|
|
||||||
|
type IoniconName = ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
type RotateSwitchProps = {
|
||||||
|
size?: SwitchSize;
|
||||||
|
leftIcon?: IoniconName;
|
||||||
|
leftIconColor?: string | OpaqueColorValue | undefined;
|
||||||
|
rightIconColor?: string | OpaqueColorValue | undefined;
|
||||||
|
rightIcon?: IoniconName;
|
||||||
|
duration?: number;
|
||||||
|
activeBackgroundColor?: string;
|
||||||
|
inactiveBackgroundColor?: string;
|
||||||
|
inactiveOverlayColor?: string;
|
||||||
|
activeOverlayColor?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SliceSwitch = ({
|
||||||
|
size = "md",
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
duration,
|
||||||
|
activeBackgroundColor = DEFAULT_ACTIVE_BG,
|
||||||
|
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
|
||||||
|
leftIconColor = "#fff",
|
||||||
|
rightIconColor = "#fff",
|
||||||
|
inactiveOverlayColor = "#000",
|
||||||
|
activeOverlayColor = "#000",
|
||||||
|
style,
|
||||||
|
onChange,
|
||||||
|
}: RotateSwitchProps) => {
|
||||||
|
const { width: containerWidth, height: containerHeight } =
|
||||||
|
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||||
|
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||||
|
const [isOn, setIsOn] = useState(false);
|
||||||
|
const [bgOn, setBgOn] = useState(false);
|
||||||
|
const progress = useRef(new Animated.Value(0)).current;
|
||||||
|
const pressScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const overlayTranslateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const listenerIdRef = useRef<string | number | null>(null);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const next = !isOn;
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
const overlayTarget = next ? containerWidth / 2 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
overlayTranslateX.stopAnimation();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
overlayTranslateX.setValue(overlayTarget);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(overlayTranslateX, {
|
||||||
|
toValue: overlayTarget,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any previous listener
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap image & background exactly at 50% progress
|
||||||
|
let swapped = false;
|
||||||
|
listenerIdRef.current = progress.addListener(({ value }) => {
|
||||||
|
if (swapped) return;
|
||||||
|
if (next && value >= 0.5) {
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!next && value <= 0.5) {
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
pressScale.stopAnimation();
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue: PRESSED_SCALE,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
pressScale.stopAnimation();
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggle}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityState={{ checked: isOn }}
|
||||||
|
style={[styles.pressable, style]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.shadowWrapper,
|
||||||
|
{
|
||||||
|
transform: [{ scale: pressScale }],
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
backgroundColor: bgOn
|
||||||
|
? activeBackgroundColor
|
||||||
|
: inactiveBackgroundColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: containerWidth / 2,
|
||||||
|
height: containerHeight * 0.95,
|
||||||
|
top: containerHeight * 0.01,
|
||||||
|
left: 0,
|
||||||
|
borderRadius: containerHeight * 0.95 / 2,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: bgOn ? activeOverlayColor : inactiveOverlayColor,
|
||||||
|
transform: [{ translateX: overlayTranslateX }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="h-full w-1/2 items-center justify-center ">
|
||||||
|
<Ionicons
|
||||||
|
name={leftIcon ?? "sunny"}
|
||||||
|
size={20}
|
||||||
|
color={leftIconColor ?? "#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="h-full w-1/2 items-center justify-center ">
|
||||||
|
<Ionicons
|
||||||
|
name={rightIcon ?? "moon"}
|
||||||
|
size={20}
|
||||||
|
color={rightIconColor ?? "#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pressable: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
shadowWrapper: {
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
knob: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SliceSwitch;
|
||||||
2
config/localization.ts
Normal file
2
config/localization.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { useI18n } from "@/hooks/use-i18n";
|
||||||
|
export { default as i18n } from "./localization/i18n";
|
||||||
27
config/localization/i18n.ts
Normal file
27
config/localization/i18n.ts
Normal 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
119
hooks/use-i18n.ts
Normal 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
197
locales/en.json
Normal 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
198
locales/vi.json
Normal 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
60
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user