Compare commits
34 Commits
44fc6848a8
...
tunztunzz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8874fbe60 | ||
| 00fd53bbd4 | |||
|
|
742d8f6bcc | ||
| f3cf10e5e6 | |||
| 862c4e42a4 | |||
|
|
e725819c01 | ||
|
|
1a534eccb0 | ||
| c26de5aefc | |||
|
|
f3b0e7b7eb | ||
| 45746a6a0f | |||
| fd80f63bbe | |||
| c19cc7e00a | |||
|
|
4d821646cf | ||
| c02b61163d | |||
|
|
53bf2d18e6 | ||
|
|
f7b05f1e08 | ||
| 25b9e831d1 | |||
| b9cd637b33 | |||
| b97e4e1097 | |||
| 04ca091f49 | |||
|
|
1b748285c9 | ||
|
|
aabd1109b2 | ||
| 1ef83c9b22 | |||
| 6288e79622 | |||
|
|
62b18e5bc0 | ||
|
|
300271fce7 | ||
|
|
2137925ba9 | ||
| e535aaa1e8 | |||
| f3ad6e02f2 | |||
|
|
efe9749a8e | ||
| 67a80c1498 | |||
| 5cc760f818 | |||
| 52d2f0f78b | |||
| eea1482a88 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,5 +3,6 @@
|
|||||||
"source.fixAll": "explicit",
|
"source.fixAll": "explicit",
|
||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.sortMembers": "explicit"
|
"source.sortMembers": "explicit"
|
||||||
}
|
},
|
||||||
|
"postman.settings.dotenv-detection-notification-visibility": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export async function login(body: Model.LoginRequestBody) {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export async function fetchGpsData() {
|
export async function fetchGpsData() {
|
||||||
return api.get<Model.GPSResonse>(API_GET_GPS);
|
return api.get<Model.GPSResponse>(API_GET_GPS);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ export default function TabLayout() {
|
|||||||
1. **State management:**
|
1. **State management:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null);
|
const [gpsData, setGpsData] = useState<Model.GPSResponse | null>(null);
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Fetch GPS data:**
|
2. **Fetch GPS data:**
|
||||||
|
|||||||
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)
|
||||||
409
MODAL_USAGE.md
Normal file
409
MODAL_USAGE.md
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
# Modal Component
|
||||||
|
|
||||||
|
Modal component tương tự như Modal của Ant Design, được tạo cho React Native/Expo.
|
||||||
|
|
||||||
|
## Cài đặt
|
||||||
|
|
||||||
|
Component này sử dụng `@expo/vector-icons` cho các icon. Đảm bảo bạn đã cài đặt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install @expo/vector-icons
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các tính năng chính
|
||||||
|
|
||||||
|
### 1. Basic Modal
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Text, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function BasicExample() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button title="Open Modal" onPress={() => setOpen(true)} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Basic Modal"
|
||||||
|
onOk={() => {
|
||||||
|
console.log("OK clicked");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>Some contents...</Text>
|
||||||
|
<Text>Some contents...</Text>
|
||||||
|
<Text>Some contents...</Text>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Async Close (với confirmLoading)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Text, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function AsyncExample() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
// Giả lập API call
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
setLoading(false);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button title="Open Modal" onPress={() => setOpen(true)} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Async Modal"
|
||||||
|
confirmLoading={loading}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>Modal will be closed after 2 seconds when you click OK.</Text>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Customized Footer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Text, Button, TouchableOpacity, StyleSheet } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function CustomFooterExample() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button title="Open Modal" onPress={() => setOpen(true)} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Custom Footer Modal"
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<View style={styles.customFooter}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.customButton}
|
||||||
|
onPress={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Return</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.customButton, styles.submitButton]}
|
||||||
|
onPress={() => {
|
||||||
|
console.log("Submit");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.buttonText, styles.submitText]}>Submit</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>Custom footer buttons</Text>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
customFooter: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
customButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#d9d9d9",
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
borderColor: "#1890ff",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
submitText: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. No Footer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Modal Without Footer"
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>Modal content without footer buttons</Text>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Centered Modal
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Centered Modal"
|
||||||
|
centered
|
||||||
|
onOk={() => setOpen(false)}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>This modal is centered on the screen</Text>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Custom Width
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Custom Width Modal"
|
||||||
|
width={700}
|
||||||
|
onOk={() => setOpen(false)}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>This modal has custom width</Text>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Confirm Modal với useModal Hook
|
||||||
|
|
||||||
|
**Đây là cách khuyến nghị sử dụng trong React Native để có context đầy đủ:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from "react";
|
||||||
|
import { View, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function HookExample() {
|
||||||
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
|
||||||
|
const showConfirm = () => {
|
||||||
|
modal.confirm({
|
||||||
|
title: "Do you want to delete these items?",
|
||||||
|
content:
|
||||||
|
"When clicked the OK button, this dialog will be closed after 1 second",
|
||||||
|
onOk: async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
console.log("OK");
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
console.log("Cancel");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showInfo = () => {
|
||||||
|
modal.info({
|
||||||
|
title: "This is a notification message",
|
||||||
|
content: "Some additional information...",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSuccess = () => {
|
||||||
|
modal.success({
|
||||||
|
title: "Success",
|
||||||
|
content: "Operation completed successfully!",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = () => {
|
||||||
|
modal.error({
|
||||||
|
title: "Error",
|
||||||
|
content: "Something went wrong!",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showWarning = () => {
|
||||||
|
modal.warning({
|
||||||
|
title: "Warning",
|
||||||
|
content: "This is a warning message!",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ padding: 20, gap: 10 }}>
|
||||||
|
{/* contextHolder phải được đặt trong component */}
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
<Button title="Confirm" onPress={showConfirm} />
|
||||||
|
<Button title="Info" onPress={showInfo} />
|
||||||
|
<Button title="Success" onPress={showSuccess} />
|
||||||
|
<Button title="Error" onPress={showError} />
|
||||||
|
<Button title="Warning" onPress={showWarning} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Update Modal Instance
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from "react";
|
||||||
|
import { View, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function UpdateExample() {
|
||||||
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
|
||||||
|
const showModal = () => {
|
||||||
|
const instance = modal.success({
|
||||||
|
title: "Loading...",
|
||||||
|
content: "Please wait...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
instance.update({
|
||||||
|
title: "Success!",
|
||||||
|
content: "Operation completed successfully!",
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Close after 4 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
instance.destroy();
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{contextHolder}
|
||||||
|
<Button title="Show Updating Modal" onPress={showModal} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Modal Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| --------------- | ----------------------------- | --------- | ------------------------------------------------------------ |
|
||||||
|
| open | boolean | false | Whether the modal dialog is visible or not |
|
||||||
|
| title | ReactNode | - | The modal dialog's title |
|
||||||
|
| closable | boolean | true | Whether a close (x) button is visible on top right or not |
|
||||||
|
| closeIcon | ReactNode | - | Custom close icon |
|
||||||
|
| maskClosable | boolean | true | Whether to close the modal dialog when the mask is clicked |
|
||||||
|
| centered | boolean | false | Centered Modal |
|
||||||
|
| width | number \| string | 520 | Width of the modal dialog |
|
||||||
|
| confirmLoading | boolean | false | Whether to apply loading visual effect for OK button |
|
||||||
|
| okText | string | 'OK' | Text of the OK button |
|
||||||
|
| cancelText | string | 'Cancel' | Text of the Cancel button |
|
||||||
|
| okType | 'primary' \| 'default' | 'primary' | Button type of the OK button |
|
||||||
|
| footer | ReactNode \| null | - | Footer content, set as footer={null} to hide default buttons |
|
||||||
|
| mask | boolean | true | Whether show mask or not |
|
||||||
|
| zIndex | number | 1000 | The z-index of the Modal |
|
||||||
|
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK button |
|
||||||
|
| onCancel | (e?) => void | - | Callback when clicking cancel button or close icon |
|
||||||
|
| afterOpenChange | (open: boolean) => void | - | Callback when animation ends |
|
||||||
|
| afterClose | () => void | - | Callback when modal is closed completely |
|
||||||
|
| destroyOnClose | boolean | false | Whether to unmount child components on close |
|
||||||
|
| keyboard | boolean | true | Whether support press back button to close (Android) |
|
||||||
|
|
||||||
|
### Modal.useModal()
|
||||||
|
|
||||||
|
Khi bạn cần sử dụng Context, bạn có thể dùng `Modal.useModal()` để tạo `contextHolder` và chèn vào children. Modal được tạo bởi hooks sẽ có tất cả context nơi `contextHolder` được đặt.
|
||||||
|
|
||||||
|
**Returns:** `[modalMethods, contextHolder]`
|
||||||
|
|
||||||
|
- `modalMethods`: Object chứa các methods
|
||||||
|
|
||||||
|
- `info(config)`: Show info modal
|
||||||
|
- `success(config)`: Show success modal
|
||||||
|
- `error(config)`: Show error modal
|
||||||
|
- `warning(config)`: Show warning modal
|
||||||
|
- `confirm(config)`: Show confirm modal
|
||||||
|
|
||||||
|
- `contextHolder`: React element cần được render trong component tree
|
||||||
|
|
||||||
|
### Modal Methods Config
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| ---------- | -------------------------------------------------------- | --------- | ----------------------------- |
|
||||||
|
| type | 'info' \| 'success' \| 'error' \| 'warning' \| 'confirm' | 'confirm' | Type of the modal |
|
||||||
|
| title | ReactNode | - | Title |
|
||||||
|
| content | ReactNode | - | Content |
|
||||||
|
| icon | ReactNode | - | Custom icon |
|
||||||
|
| okText | string | 'OK' | Text of the OK button |
|
||||||
|
| cancelText | string | 'Cancel' | Text of the Cancel button |
|
||||||
|
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK |
|
||||||
|
| onCancel | (e?) => void | - | Callback when clicking Cancel |
|
||||||
|
|
||||||
|
### Modal Instance
|
||||||
|
|
||||||
|
Modal instance được trả về bởi `Modal.useModal()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ModalInstance {
|
||||||
|
destroy: () => void;
|
||||||
|
update: (config: ConfirmModalProps) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lưu ý
|
||||||
|
|
||||||
|
1. **React Native Limitations**: Các static methods như `Modal.info()`, `Modal.confirm()` gọi trực tiếp (không qua hook) không được hỗ trợ đầy đủ trong React Native do không thể render imperatively. Hãy sử dụng `Modal.useModal()` hook thay thế.
|
||||||
|
|
||||||
|
2. **Context Support**: Khi cần sử dụng Context (như Redux, Theme Context), bắt buộc phải dùng `Modal.useModal()` hook và đặt `contextHolder` trong component tree.
|
||||||
|
|
||||||
|
3. **Animation**: Modal sử dụng React Native's built-in Modal với `animationType="fade"`.
|
||||||
|
|
||||||
|
4. **Icons**: Component sử dụng `@expo/vector-icons` (Ionicons). Đảm bảo đã cài đặt package này.
|
||||||
|
|
||||||
|
5. **Keyboard**: Prop `keyboard` trong React Native chỉ hoạt động với nút back của Android (không có ESC key như web).
|
||||||
|
|
||||||
|
## So sánh với Ant Design Modal
|
||||||
|
|
||||||
|
| Feature | Ant Design (Web) | This Component (RN) |
|
||||||
|
| --------------------------- | ---------------- | --------------------------- |
|
||||||
|
| Basic Modal | ✅ | ✅ |
|
||||||
|
| Centered | ✅ | ✅ |
|
||||||
|
| Custom Footer | ✅ | ✅ |
|
||||||
|
| Confirm Dialog | ✅ | ✅ (via useModal) |
|
||||||
|
| Info/Success/Error/Warning | ✅ | ✅ (via useModal) |
|
||||||
|
| Async close | ✅ | ✅ |
|
||||||
|
| Custom width | ✅ | ✅ |
|
||||||
|
| Mask closable | ✅ | ✅ |
|
||||||
|
| Keyboard close | ✅ (ESC) | ✅ (Back button on Android) |
|
||||||
|
| Static methods without hook | ✅ | ⚠️ (Limited support) |
|
||||||
|
| useModal hook | ✅ | ✅ (Recommended) |
|
||||||
|
| Draggable | ✅ | ❌ (Not applicable) |
|
||||||
|
| destroyAll() | ✅ | ❌ |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
32
README.md
32
README.md
@@ -48,3 +48,35 @@ Join our community of developers creating universal apps.
|
|||||||
|
|
||||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||||
|
|
||||||
|
## Build app
|
||||||
|
|
||||||
|
- Add eas.json file to root folder and add this:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.27.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|||||||
234
THEME_GUIDE.md
Normal file
234
THEME_GUIDE.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# Theme System Documentation
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
Hệ thống theme đã được cấu hình để hỗ trợ Light Mode, Dark Mode và System Mode (tự động theo hệ thống). Theme được lưu trữ trong AsyncStorage và sẽ được khôi phục khi khởi động lại ứng dụng.
|
||||||
|
|
||||||
|
## Cấu trúc Theme
|
||||||
|
|
||||||
|
### 1. Colors Configuration (`constants/theme.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const Colors = {
|
||||||
|
light: {
|
||||||
|
text: "#11181C",
|
||||||
|
textSecondary: "#687076",
|
||||||
|
background: "#fff",
|
||||||
|
backgroundSecondary: "#f5f5f5",
|
||||||
|
surface: "#ffffff",
|
||||||
|
surfaceSecondary: "#f8f9fa",
|
||||||
|
primary: "#007AFF",
|
||||||
|
secondary: "#5AC8FA",
|
||||||
|
success: "#34C759",
|
||||||
|
warning: "#FF9500",
|
||||||
|
error: "#FF3B30",
|
||||||
|
// ... more colors
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: "#ECEDEE",
|
||||||
|
textSecondary: "#8E8E93",
|
||||||
|
background: "#000000",
|
||||||
|
backgroundSecondary: "#1C1C1E",
|
||||||
|
surface: "#1C1C1E",
|
||||||
|
surfaceSecondary: "#2C2C2E",
|
||||||
|
primary: "#0A84FF",
|
||||||
|
secondary: "#64D2FF",
|
||||||
|
success: "#30D158",
|
||||||
|
warning: "#FF9F0A",
|
||||||
|
error: "#FF453A",
|
||||||
|
// ... more colors
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Theme Context (`hooks/use-theme-context.tsx`)
|
||||||
|
|
||||||
|
Cung cấp theme state và functions cho toàn bộ app:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ThemeContextType {
|
||||||
|
themeMode: ThemeMode; // 'light' | 'dark' | 'system'
|
||||||
|
colorScheme: ColorScheme; // 'light' | 'dark'
|
||||||
|
colors: typeof Colors.light;
|
||||||
|
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||||
|
getColor: (colorName: ColorName) => string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách sử dụng Theme
|
||||||
|
|
||||||
|
### 1. Sử dụng Themed Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<ThemedText type="title">Title Text</ThemedText>
|
||||||
|
<ThemedText type="default">Regular Text</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Sử dụng Theme Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { colors, colorScheme, setThemeMode } = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ backgroundColor: colors.background }}>
|
||||||
|
<Text style={{ color: colors.text }}>Current theme: {colorScheme}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Sử dụng App Theme Hook (Recommended)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { colors, styles, utils } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity style={styles.primaryButton}>
|
||||||
|
<Text style={styles.primaryButtonText}>Button</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.surface,
|
||||||
|
{
|
||||||
|
backgroundColor: utils.getOpacityColor("primary", 0.1),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Theme is {utils.isDark ? "Dark" : "Light"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Sử dụng useThemeColor Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
// Override colors for specific themes
|
||||||
|
const backgroundColor = useThemeColor(
|
||||||
|
{ light: "#ffffff", dark: "#1C1C1E" },
|
||||||
|
"surface"
|
||||||
|
);
|
||||||
|
|
||||||
|
const textColor = useThemeColor({}, "text");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ backgroundColor }}>
|
||||||
|
<Text style={{ color: textColor }}>Text</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Toggle Component
|
||||||
|
|
||||||
|
Sử dụng `ThemeToggle` component để cho phép user chọn theme:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
|
function SettingsScreen() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ThemeToggle />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Styles từ useAppTheme
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { styles } = useAppTheme();
|
||||||
|
|
||||||
|
// Container styles
|
||||||
|
styles.container; // Flex 1 container với background
|
||||||
|
styles.surface; // Card surface với padding
|
||||||
|
styles.card; // Card với shadow và border radius
|
||||||
|
|
||||||
|
// Button styles
|
||||||
|
styles.primaryButton; // Primary button style
|
||||||
|
styles.secondaryButton; // Secondary button với border
|
||||||
|
styles.primaryButtonText; // White text cho primary button
|
||||||
|
styles.secondaryButtonText; // Theme text cho secondary button
|
||||||
|
|
||||||
|
// Input styles
|
||||||
|
styles.textInput; // Text input với border và padding
|
||||||
|
|
||||||
|
// Status styles
|
||||||
|
styles.successContainer; // Success status container
|
||||||
|
styles.warningContainer; // Warning status container
|
||||||
|
styles.errorContainer; // Error status container
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
styles.separator; // Line separator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Utilities
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { utils } = useAppTheme();
|
||||||
|
|
||||||
|
utils.isDark; // boolean - kiểm tra dark mode
|
||||||
|
utils.isLight; // boolean - kiểm tra light mode
|
||||||
|
utils.toggleTheme(); // function - toggle giữa light/dark
|
||||||
|
utils.getOpacityColor(colorName, opacity); // Tạo màu với opacity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lưu trữ Theme Preference
|
||||||
|
|
||||||
|
Theme preference được tự động lưu trong AsyncStorage với key `'theme_mode'`. Khi app khởi động, theme sẽ được khôi phục từ storage.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Sử dụng `useAppTheme`** thay vì access colors trực tiếp
|
||||||
|
2. **Sử dụng pre-defined styles** từ `useAppTheme().styles`
|
||||||
|
3. **Kiểm tra theme** bằng `utils.isDark` thay vì check colorScheme
|
||||||
|
4. **Sử dụng opacity colors** cho backgrounds: `utils.getOpacityColor('primary', 0.1)`
|
||||||
|
5. **Tận dụng ThemedText và ThemedView** cho các component đơn giản
|
||||||
|
|
||||||
|
## Migration từ theme cũ
|
||||||
|
|
||||||
|
Nếu bạn đang sử dụng theme cũ:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Cũ
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const backgroundColor = colorScheme === "dark" ? "#000" : "#fff";
|
||||||
|
|
||||||
|
// Mới
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const backgroundColor = colors.background;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
1. **Theme không được lưu**: Kiểm tra AsyncStorage permissions
|
||||||
|
2. **Flash khi khởi động**: ThemeProvider sẽ chờ load theme trước khi render
|
||||||
|
3. **Colors không đúng**: Đảm bảo component được wrap trong ThemeProvider
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Xem `components/theme-example.tsx` để biết các cách sử dụng theme khác nhau.
|
||||||
44
app.json
44
app.json
@@ -9,7 +9,14 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"CFBundleLocalizations": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundleIdentifier": "com.minhnn86.sgwapp"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -19,7 +26,12 @@
|
|||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO"
|
||||||
|
],
|
||||||
|
"package": "com.minhnn86.sgwapp"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "static",
|
||||||
@@ -28,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"expo-system-ui",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -39,11 +52,38 @@
|
|||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-localization",
|
||||||
|
{
|
||||||
|
"supportedLocales": {
|
||||||
|
"ios": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
],
|
||||||
|
"android": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
"reactCompiler": true
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "d4ef1318-5427-4c96-ad88-97ec117829cc"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
import { Tabs } from "expo-router";
|
import { Tabs, useSegments } from "expo-router";
|
||||||
|
|
||||||
import { HapticTab } from "@/components/haptic-tab";
|
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 { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
const segments = useSegments() as string[];
|
||||||
|
const prev = useRef<string | null>(null);
|
||||||
|
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
useEffect(() => {
|
||||||
|
if (prev.current !== currentSegment) {
|
||||||
|
// console.log("Tab changed ->", { from: prev.current, to: currentSegment });
|
||||||
|
// TODO: xử lý khi chuyển tab ở đây
|
||||||
|
if (prev.current === "(tabs)" && currentSegment !== "(tabs)") {
|
||||||
|
stopEvents();
|
||||||
|
console.log("Stop events");
|
||||||
|
} else if (prev.current !== "(tabs)" && currentSegment === "(tabs)") {
|
||||||
|
// we came back into the tabs group — restart polling
|
||||||
|
startEvents();
|
||||||
|
console.log("start events");
|
||||||
|
}
|
||||||
|
prev.current = currentSegment;
|
||||||
|
}
|
||||||
|
}, [currentSegment]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -19,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} />
|
||||||
),
|
),
|
||||||
@@ -29,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} />
|
||||||
),
|
),
|
||||||
@@ -38,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} />
|
||||||
),
|
),
|
||||||
@@ -47,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}
|
||||||
@@ -60,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} />
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import CreateOrUpdateHaulModal from "@/components/tripInfo/modal/CreateOrUpdateHaulModal";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Platform, StyleSheet, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function Warning() {
|
export default function Warning() {
|
||||||
|
const [isShowModal, setIsShowModal] = useState(false);
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.titleText}>Nhật Ký Chuyến Đi</Text>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,4 +31,17 @@ const styles = StyleSheet.create({
|
|||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,369 +1,206 @@
|
|||||||
|
import GPSInfoPanel from "@/components/map/GPSInfoPanel";
|
||||||
|
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
||||||
import { PolygonWithLabel } from "@/components/map/PolygonWithLabel";
|
import { PolygonWithLabel } from "@/components/map/PolygonWithLabel";
|
||||||
|
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
||||||
import { PolylineWithLabel } from "@/components/map/PolylineWithLabel";
|
import { PolylineWithLabel } from "@/components/map/PolylineWithLabel";
|
||||||
import { showToastError } from "@/config";
|
import SosButton from "@/components/map/SosButton";
|
||||||
import {
|
import {
|
||||||
AUTO_REFRESH_INTERVAL,
|
|
||||||
ENTITY,
|
ENTITY,
|
||||||
|
EVENT_ALARM_DATA,
|
||||||
|
EVENT_BANZONE_DATA,
|
||||||
|
EVENT_ENTITY_DATA,
|
||||||
|
EVENT_GPS_DATA,
|
||||||
|
EVENT_TRACK_POINTS_DATA,
|
||||||
IOS_PLATFORM,
|
IOS_PLATFORM,
|
||||||
LIGHT_THEME,
|
LIGHT_THEME,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
import {
|
|
||||||
queryAlarm,
|
|
||||||
queryEntities,
|
|
||||||
queryGpsData,
|
|
||||||
queryTrackPoints,
|
|
||||||
} from "@/controller/DeviceController";
|
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme.web";
|
import { useColorScheme } from "@/hooks/use-color-scheme.web";
|
||||||
import { usePlatform } from "@/hooks/use-platform";
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import {
|
||||||
|
getAlarmEventBus,
|
||||||
|
getBanzonesEventBus,
|
||||||
|
getEntitiesEventBus,
|
||||||
|
getGpsEventBus,
|
||||||
|
getTrackPointsEventBus,
|
||||||
|
} from "@/services/device_events";
|
||||||
import { getShipIcon } from "@/services/map_service";
|
import { getShipIcon } from "@/services/map_service";
|
||||||
import { useBanzones } from "@/state/use-banzones";
|
import eventBus from "@/utils/eventBus";
|
||||||
import {
|
import {
|
||||||
convertWKTLineStringToLatLngArray,
|
convertWKTLineStringToLatLngArray,
|
||||||
convertWKTtoLatLngString,
|
convertWKTtoLatLngString,
|
||||||
} from "@/utils/geom";
|
} from "@/utils/geom";
|
||||||
import { Image as ExpoImage } from "expo-image";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Image as RNImage, StyleSheet, View } from "react-native";
|
||||||
|
|
||||||
import MapView, { Circle, Marker } from "react-native-maps";
|
import MapView, { Circle, Marker } from "react-native-maps";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const testPolyline =
|
|
||||||
"MULTIPOLYGON(((108.7976074 17.5392966,110.390625 14.2217886,109.4677734 10.8548863,112.9227161 10.6933337,116.4383411 12.565622,116.8997669 17.0466095,109.8685169 17.8013229,108.7973446 17.5393669,108.7976074 17.5392966)))";
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null);
|
const [gpsData, setGpsData] = useState<Model.GPSResponse | null>(null);
|
||||||
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
||||||
const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>(
|
const [entityData, setEntityData] = useState<
|
||||||
null
|
Model.TransformedEntity[] | null
|
||||||
);
|
>(null);
|
||||||
|
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
||||||
|
const [trackPointsData, setTrackPointsData] = useState<
|
||||||
|
Model.ShipTrackPoint[] | null
|
||||||
|
>(null);
|
||||||
const [circleRadius, setCircleRadius] = useState(100);
|
const [circleRadius, setCircleRadius] = useState(100);
|
||||||
const [zoomLevel, setZoomLevel] = useState(10);
|
const [zoomLevel, setZoomLevel] = useState(10);
|
||||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||||
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
||||||
number[][] | null
|
PolylineWithLabelProps[]
|
||||||
>(null);
|
>([]);
|
||||||
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||||
number[][][] | null
|
PolygonWithLabelProps[]
|
||||||
>(null);
|
>([]);
|
||||||
const [, setZoneGeometries] = useState<Map<string, any>>(new Map());
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const theme = useColorScheme();
|
const theme = useThemeContext().colorScheme;
|
||||||
const { banzones, getBanzone } = useBanzones();
|
const scale = useRef(new Animated.Value(0)).current;
|
||||||
const banzonesRef = useRef(banzones);
|
const opacity = useRef(new Animated.Value(1)).current;
|
||||||
// console.log("Platform: ", platform);
|
|
||||||
// console.log("Theme: ", theme);
|
|
||||||
|
|
||||||
const getGpsData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await queryGpsData();
|
|
||||||
// console.log("GpsData: ", response.data);
|
|
||||||
// console.log(
|
|
||||||
// "Heading value:",
|
|
||||||
// response.data?.h,
|
|
||||||
// "Type:",
|
|
||||||
// typeof response.data?.h
|
|
||||||
// );
|
|
||||||
setGpsData(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching GPS data:", error);
|
|
||||||
showToastError("Lỗi", "Không thể lấy dữ liệu GPS");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawPolyline = () => {
|
|
||||||
const data = convertWKTtoLatLngString(testPolyline);
|
|
||||||
console.log("Data: ", data);
|
|
||||||
// setPolygonCoordinates(data[0]);
|
|
||||||
// ;
|
|
||||||
// console.log("Banzones: ", banzones.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
banzonesRef.current = banzones;
|
getGpsEventBus();
|
||||||
}, [banzones]);
|
getAlarmEventBus();
|
||||||
|
getEntitiesEventBus();
|
||||||
const areGeometriesEqual = (
|
getBanzonesEventBus();
|
||||||
left?: {
|
getTrackPointsEventBus();
|
||||||
geom_type: number;
|
const queryGpsData = (gpsData: Model.GPSResponse) => {
|
||||||
geom_lines?: string | null;
|
if (gpsData) {
|
||||||
geom_poly?: string | null;
|
// console.log("GPS Data: ", gpsData);
|
||||||
},
|
setGpsData(gpsData);
|
||||||
right?: {
|
} else {
|
||||||
geom_type: number;
|
setGpsData(null);
|
||||||
geom_lines?: string | null;
|
setPolygonCoordinates([]);
|
||||||
geom_poly?: string | null;
|
setPolylineCoordinates([]);
|
||||||
}
|
|
||||||
) => {
|
|
||||||
if (!left && !right) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!left || !right) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
left.geom_type === right.geom_type &&
|
|
||||||
(left.geom_lines || "") === (right.geom_lines || "") &&
|
|
||||||
(left.geom_poly || "") === (right.geom_poly || "")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const areCoordinatesEqual = (
|
|
||||||
current: number[][] | null,
|
|
||||||
next: number[][] | null
|
|
||||||
) => {
|
|
||||||
if (!current || !next || current.length !== next.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return current.every(
|
|
||||||
(coord, index) =>
|
|
||||||
coord[0] === next[index][0] && coord[1] === next[index][1]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const areMultiPolygonCoordinatesEqual = (
|
|
||||||
current: number[][][] | null,
|
|
||||||
next: number[][][] | null
|
|
||||||
) => {
|
|
||||||
if (!current || !next || current.length !== next.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return current.every((polygon, polyIndex) => {
|
|
||||||
const nextPolygon = next[polyIndex];
|
|
||||||
if (!nextPolygon || polygon.length !== nextPolygon.length) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return polygon.every(
|
};
|
||||||
(coord, coordIndex) =>
|
const queryAlarmData = (alarmData: Model.AlarmResponse) => {
|
||||||
coord[0] === nextPolygon[coordIndex][0] &&
|
// console.log("Alarm Data: ", alarmData.alarms.length);
|
||||||
coord[1] === nextPolygon[coordIndex][1]
|
setAlarmData(alarmData);
|
||||||
);
|
};
|
||||||
});
|
const queryEntityData = (entityData: Model.TransformedEntity[]) => {
|
||||||
};
|
// console.log("Entities Length Data: ", entityData.length);
|
||||||
|
setEntityData(entityData);
|
||||||
|
};
|
||||||
|
const queryBanzonesData = (banzoneData: Model.Zone[]) => {
|
||||||
|
// console.log("Banzone Data: ", banzoneData.length);
|
||||||
|
|
||||||
const getAlarmData = async () => {
|
setBanzoneData(banzoneData);
|
||||||
try {
|
};
|
||||||
const response = await queryAlarm();
|
const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
|
||||||
// console.log("AlarmData: ", response.data);
|
// console.log("TrackPoints Data: ", TrackPointsData.length);
|
||||||
setAlarmData(response.data);
|
if (TrackPointsData && TrackPointsData.length > 0) {
|
||||||
} catch (error) {
|
setTrackPointsData(TrackPointsData);
|
||||||
console.error("Error fetching Alarm Data: ", error);
|
} else {
|
||||||
showToastError("Lỗi", "Không thể lấy dữ liệu báo động");
|
setTrackPointsData([]);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEntities = async () => {
|
|
||||||
try {
|
|
||||||
const entities = await queryEntities();
|
|
||||||
if (!entities) {
|
|
||||||
// Clear tất cả khu vực khi không có dữ liệu
|
|
||||||
setPolylineCoordinates(null);
|
|
||||||
setPolygonCoordinates(null);
|
|
||||||
setZoneGeometries(new Map());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const currentBanzones = banzonesRef.current || [];
|
eventBus.on(EVENT_GPS_DATA, queryGpsData);
|
||||||
let nextPolyline: number[][] | null = null;
|
// console.log("Registering event handlers in HomeScreen");
|
||||||
let nextMultiPolygon: number[][][] | null = null;
|
eventBus.on(EVENT_GPS_DATA, queryGpsData);
|
||||||
let foundPolyline = false;
|
// console.log("Subscribed to EVENT_GPS_DATA");
|
||||||
let foundPolygon = false;
|
eventBus.on(EVENT_ALARM_DATA, queryAlarmData);
|
||||||
|
// console.log("Subscribed to EVENT_ALARM_DATA");
|
||||||
|
eventBus.on(EVENT_ENTITY_DATA, queryEntityData);
|
||||||
|
// console.log("Subscribed to EVENT_ENTITY_DATA");
|
||||||
|
eventBus.on(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
|
||||||
|
// console.log("Subscribed to EVENT_TRACK_POINTS_DATA");
|
||||||
|
eventBus.once(EVENT_BANZONE_DATA, queryBanzonesData);
|
||||||
|
// console.log("Subscribed once to EVENT_BANZONE_DATA");
|
||||||
|
|
||||||
// Process zones để tìm geometries
|
|
||||||
for (const entity of entities) {
|
|
||||||
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let zones: any[] = [];
|
|
||||||
try {
|
|
||||||
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error("Error parsing zone list:", parseError);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nếu danh sách zone rỗng, clear tất cả
|
|
||||||
if (zones.length === 0) {
|
|
||||||
setPolylineCoordinates(null);
|
|
||||||
setPolygonCoordinates(null);
|
|
||||||
setZoneGeometries(new Map());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const zone of zones) {
|
|
||||||
const geom = currentBanzones.find((b) => b.id === zone.zone_id);
|
|
||||||
if (!geom) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
|
||||||
if (typeof geom_type !== "number") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geom_type === 2) {
|
|
||||||
foundPolyline = true;
|
|
||||||
const coordinates = convertWKTLineStringToLatLngArray(
|
|
||||||
geom_lines || ""
|
|
||||||
);
|
|
||||||
if (coordinates.length > 0) {
|
|
||||||
nextPolyline = coordinates;
|
|
||||||
}
|
|
||||||
} else if (geom_type === 1) {
|
|
||||||
foundPolygon = true;
|
|
||||||
const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
|
||||||
if (coordinates.length > 0) {
|
|
||||||
console.log("Polygon Coordinate: ", coordinates);
|
|
||||||
nextMultiPolygon = coordinates;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state sau khi đã process xong
|
|
||||||
setZoneGeometries((prevGeometries) => {
|
|
||||||
const updated = new Map(prevGeometries);
|
|
||||||
let hasChanges = false;
|
|
||||||
|
|
||||||
for (const entity of entities) {
|
|
||||||
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let zones: any[] = [];
|
|
||||||
try {
|
|
||||||
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zones.length === 0) {
|
|
||||||
if (updated.size > 0) {
|
|
||||||
hasChanges = true;
|
|
||||||
updated.clear();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const zone of zones) {
|
|
||||||
const geom = currentBanzones.find((b) => b.id === zone.zone_id);
|
|
||||||
if (!geom) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
|
||||||
if (typeof geom_type !== "number") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${zone.zone_id}_${geom_type}`;
|
|
||||||
const newGeomData = { geom_type, geom_lines, geom_poly };
|
|
||||||
const oldGeom = updated.get(key);
|
|
||||||
|
|
||||||
if (!areGeometriesEqual(oldGeom, newGeomData)) {
|
|
||||||
hasChanges = true;
|
|
||||||
updated.set(key, newGeomData);
|
|
||||||
console.log("Geometry changed", { key, oldGeom, newGeomData });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasChanges ? updated : prevGeometries;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cập nhật hoặc clear polyline
|
|
||||||
if (foundPolyline && nextPolyline) {
|
|
||||||
setPolylineCoordinates((prev) =>
|
|
||||||
areCoordinatesEqual(prev, nextPolyline) ? prev : nextPolyline
|
|
||||||
);
|
|
||||||
} else if (!foundPolyline) {
|
|
||||||
console.log("Hết cảnh báo qua polyline");
|
|
||||||
setPolylineCoordinates(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cập nhật hoặc clear polygon
|
|
||||||
if (foundPolygon && nextMultiPolygon) {
|
|
||||||
setPolygonCoordinates((prev) =>
|
|
||||||
areMultiPolygonCoordinatesEqual(prev, nextMultiPolygon)
|
|
||||||
? prev
|
|
||||||
: nextMultiPolygon
|
|
||||||
);
|
|
||||||
} else if (!foundPolygon) {
|
|
||||||
console.log("Hết cảnh báo qua polygon");
|
|
||||||
setPolygonCoordinates(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching Entities: ", error);
|
|
||||||
// Clear tất cả khi có lỗi
|
|
||||||
setPolylineCoordinates(null);
|
|
||||||
setPolygonCoordinates(null);
|
|
||||||
setZoneGeometries(new Map());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getShipTrackPoints = async () => {
|
|
||||||
try {
|
|
||||||
const response = await queryTrackPoints();
|
|
||||||
// console.log("TrackPoints Data Length: ", response.data.length);
|
|
||||||
setTrackPoints(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching TrackPoints Data: ", error);
|
|
||||||
showToastError("Lỗi", "Không thể lấy lịch sử di chuyển");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchAllData = async () => {
|
|
||||||
await Promise.all([
|
|
||||||
getGpsData(),
|
|
||||||
getAlarmData(),
|
|
||||||
getShipTrackPoints(),
|
|
||||||
getEntities(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupAutoRefresh = () => {
|
|
||||||
// Clear existing interval if any
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set new interval to refresh data every 5 seconds
|
|
||||||
// Không fetch banzones vì dữ liệu không thay đổi
|
|
||||||
intervalRef.current = setInterval(async () => {
|
|
||||||
// console.log("Auto-refreshing data...");
|
|
||||||
await fetchAllData();
|
|
||||||
}, AUTO_REFRESH_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMapReady = () => {
|
|
||||||
// console.log("Map loaded successfully!");
|
|
||||||
// Gọi fetchAllData ngay lập tức (không cần đợi banzones)
|
|
||||||
fetchAllData();
|
|
||||||
setupAutoRefresh();
|
|
||||||
// Set isFirstLoad to false sau khi map ready để chỉ zoom lần đầu tiên
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsFirstLoad(false);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup interval on component unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
// console.log("Unregistering event handlers in HomeScreen");
|
||||||
clearInterval(intervalRef.current);
|
eventBus.off(EVENT_GPS_DATA, queryGpsData);
|
||||||
}
|
// console.log("Unsubscribed EVENT_GPS_DATA");
|
||||||
|
eventBus.off(EVENT_ALARM_DATA, queryAlarmData);
|
||||||
|
// console.log("Unsubscribed EVENT_ALARM_DATA");
|
||||||
|
eventBus.off(EVENT_ENTITY_DATA, queryEntityData);
|
||||||
|
// console.log("Unsubscribed EVENT_ENTITY_DATA");
|
||||||
|
eventBus.off(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
|
||||||
|
// console.log("Unsubscribed EVENT_TRACK_POINTS_DATA");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getBanzone();
|
setPolylineCoordinates([]);
|
||||||
}, [getBanzone]);
|
setPolygonCoordinates([]);
|
||||||
|
if (!entityData) return;
|
||||||
|
if (!banzoneData) return;
|
||||||
|
for (const entity of entityData) {
|
||||||
|
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zones: any[] = [];
|
||||||
|
try {
|
||||||
|
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error("Error parsing zone list:", parseError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Nếu danh sách zone rỗng, clear tất cả
|
||||||
|
if (zones.length === 0) {
|
||||||
|
setPolylineCoordinates([]);
|
||||||
|
setPolygonCoordinates([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let polylines: PolylineWithLabelProps[] = [];
|
||||||
|
let polygons: PolygonWithLabelProps[] = [];
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
// console.log("Zone Data: ", zone);
|
||||||
|
const geom = banzoneData.find((b) => b.id === zone.zone_id);
|
||||||
|
if (!geom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||||
|
if (typeof geom_type !== "number") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (geom_type === 2) {
|
||||||
|
// if(oldEntityData.find(e => e.id === ))
|
||||||
|
// foundPolyline = true;
|
||||||
|
const coordinates = convertWKTLineStringToLatLngArray(
|
||||||
|
geom_lines || ""
|
||||||
|
);
|
||||||
|
if (coordinates.length > 0) {
|
||||||
|
polylines.push({
|
||||||
|
coordinates: coordinates.map((coord) => ({
|
||||||
|
latitude: coord[0],
|
||||||
|
longitude: coord[1],
|
||||||
|
})),
|
||||||
|
label: zone?.zone_name ?? "",
|
||||||
|
content: zone?.message ?? "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Không tìm thấy polyline trong alarm");
|
||||||
|
}
|
||||||
|
} else if (geom_type === 1) {
|
||||||
|
// foundPolygon = true;
|
||||||
|
const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
||||||
|
if (coordinates.length > 0) {
|
||||||
|
// console.log("Polygon Coordinate: ", coordinates);
|
||||||
|
const zonePolygons = coordinates.map((polygon) => ({
|
||||||
|
coordinates: polygon.map((coord) => ({
|
||||||
|
latitude: coord[0],
|
||||||
|
longitude: coord[1],
|
||||||
|
})),
|
||||||
|
label: zone?.zone_name ?? "",
|
||||||
|
content: zone?.message ?? "",
|
||||||
|
}));
|
||||||
|
polygons.push(...zonePolygons);
|
||||||
|
} else {
|
||||||
|
console.log("Không tìm thấy polygon trong alarm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPolylineCoordinates(polylines);
|
||||||
|
setPolygonCoordinates(polygons);
|
||||||
|
}
|
||||||
|
}, [banzoneData, entityData]);
|
||||||
|
|
||||||
// Hàm tính radius cố định khi zoom change
|
// Hàm tính radius cố định khi zoom change
|
||||||
const calculateRadiusFromZoom = (zoom: number) => {
|
const calculateRadiusFromZoom = (zoom: number) => {
|
||||||
@@ -410,16 +247,54 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMapReady = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsFirstLoad(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (alarmData?.level === 3) {
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: 3, // nở to 3 lần
|
||||||
|
duration: 1500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 0, // mờ dần
|
||||||
|
duration: 1500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}
|
||||||
|
}, [alarmData?.level, scale, opacity]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider style={styles.container}>
|
<View
|
||||||
{banzones.length > 0 && (
|
// edges={["top"]}
|
||||||
<Text className="hidden">Banzones loaded: {banzones.length}</Text>
|
style={styles.container}
|
||||||
)}
|
>
|
||||||
<MapView
|
<MapView
|
||||||
onMapReady={handleMapReady}
|
onMapReady={handleMapReady}
|
||||||
onPoiClick={(point) => {
|
|
||||||
console.log("Poi clicked: ", point.nativeEvent);
|
|
||||||
}}
|
|
||||||
onRegionChangeComplete={handleRegionChangeComplete}
|
onRegionChangeComplete={handleRegionChangeComplete}
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
// initialRegion={getMapRegion()}
|
// initialRegion={getMapRegion()}
|
||||||
@@ -428,107 +303,134 @@ export default function HomeScreen() {
|
|||||||
showsBuildings={false}
|
showsBuildings={false}
|
||||||
showsIndoors={false}
|
showsIndoors={false}
|
||||||
loadingEnabled={true}
|
loadingEnabled={true}
|
||||||
mapType="standard"
|
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
||||||
|
rotateEnabled={false}
|
||||||
>
|
>
|
||||||
{trackPoints &&
|
{trackPointsData &&
|
||||||
trackPoints.length > 0 &&
|
trackPointsData.length > 0 &&
|
||||||
trackPoints.map((point, index) => {
|
trackPointsData.map((point, index) => {
|
||||||
// console.log(`Rendering circle ${index}:`, point);
|
// console.log(`Rendering circle ${index}:`, point);
|
||||||
return (
|
return (
|
||||||
<Circle
|
<Circle
|
||||||
key={index}
|
key={`circle-${index}`}
|
||||||
center={{
|
center={{
|
||||||
latitude: point.lat,
|
latitude: point.lat,
|
||||||
longitude: point.lon,
|
longitude: point.lon,
|
||||||
}}
|
}}
|
||||||
zIndex={50}
|
// zIndex={50}
|
||||||
|
// radius={platform === IOS_PLATFORM ? 200 : 50}
|
||||||
radius={circleRadius}
|
radius={circleRadius}
|
||||||
fillColor="rgba(16, 85, 201, 0.6)"
|
strokeColor="rgba(16, 85, 201, 0.7)"
|
||||||
strokeColor="rgba(16, 85, 201, 0.8)"
|
fillColor="rgba(16, 85, 201, 0.7)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{polylineCoordinates && (
|
{polylineCoordinates.length > 0 && (
|
||||||
<PolylineWithLabel
|
<>
|
||||||
coordinates={polylineCoordinates.map((coord) => ({
|
{polylineCoordinates.map((polyline, index) => (
|
||||||
latitude: coord[0],
|
<PolylineWithLabel
|
||||||
longitude: coord[1],
|
key={`polyline-${index}-${gpsData?.lat || 0}-${
|
||||||
}))}
|
gpsData?.lon || 0
|
||||||
label="Tuyến bờ"
|
}`}
|
||||||
strokeColor="#FF5733"
|
coordinates={polyline.coordinates}
|
||||||
strokeWidth={4}
|
label={polyline.label}
|
||||||
showDistance={false}
|
content={polyline.content}
|
||||||
zIndex={50}
|
strokeColor="#FF5733"
|
||||||
/>
|
strokeWidth={4}
|
||||||
|
showDistance={false}
|
||||||
|
// zIndex={50}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{polygonCoordinates && polygonCoordinates.length > 0 && (
|
{polygonCoordinates.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{polygonCoordinates.map((polygon, index) => {
|
{polygonCoordinates.map((polygon, index) => {
|
||||||
// Tạo key ổn định từ tọa độ đầu tiên của polygon
|
|
||||||
const polygonKey =
|
|
||||||
polygon.length > 0
|
|
||||||
? `polygon-${polygon[0][0]}-${polygon[0][1]}-${index}`
|
|
||||||
: `polygon-${index}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PolygonWithLabel
|
<PolygonWithLabel
|
||||||
key={polygonKey}
|
key={`polygon-${index}-${gpsData?.lat || 0}-${
|
||||||
coordinates={polygon.map((coords) => ({
|
gpsData?.lon || 0
|
||||||
latitude: coords[0],
|
}`}
|
||||||
longitude: coords[1],
|
coordinates={polygon.coordinates}
|
||||||
}))}
|
label={polygon.label}
|
||||||
label="Test khu đánh bắt"
|
content={polygon.content}
|
||||||
content="Thời gian cấm (từ tháng 1 đến tháng 12)"
|
|
||||||
fillColor="rgba(16, 85, 201, 0.6)"
|
fillColor="rgba(16, 85, 201, 0.6)"
|
||||||
strokeColor="rgba(16, 85, 201, 0.8)"
|
strokeColor="rgba(16, 85, 201, 0.8)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
zIndex={50}
|
// zIndex={50}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{gpsData && (
|
{gpsData !== null && (
|
||||||
<Marker
|
<Marker
|
||||||
|
key={
|
||||||
|
platform === IOS_PLATFORM
|
||||||
|
? `${gpsData.lat}-${gpsData.lon}`
|
||||||
|
: "gps-data"
|
||||||
|
}
|
||||||
coordinate={{
|
coordinate={{
|
||||||
latitude: gpsData.lat,
|
latitude: gpsData.lat,
|
||||||
longitude: gpsData.lon,
|
longitude: gpsData.lon,
|
||||||
}}
|
}}
|
||||||
title={
|
zIndex={20}
|
||||||
|
anchor={
|
||||||
platform === IOS_PLATFORM
|
platform === IOS_PLATFORM
|
||||||
? "Tàu của mình - iOS"
|
? { x: 0.5, y: 0.5 }
|
||||||
: "Tàu của mình - Android"
|
: { x: 0.6, y: 0.4 }
|
||||||
}
|
}
|
||||||
zIndex={100}
|
tracksViewChanges={platform === IOS_PLATFORM ? true : undefined}
|
||||||
anchor={{ x: 0.5, y: 0.5 }}
|
|
||||||
>
|
>
|
||||||
<View className="w-8 h-8 items-center justify-center">
|
<View className="w-8 h-8 items-center justify-center">
|
||||||
<ExpoImage
|
<View style={styles.pingContainer}>
|
||||||
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)}
|
{alarmData?.level === 3 && (
|
||||||
style={{
|
<Animated.View
|
||||||
width: 32,
|
style={[
|
||||||
height: 32,
|
styles.pingCircle,
|
||||||
transform: [
|
{
|
||||||
{
|
transform: [{ scale }],
|
||||||
rotate: `${
|
opacity,
|
||||||
typeof gpsData.h === "number" && !isNaN(gpsData.h)
|
},
|
||||||
? gpsData.h
|
]}
|
||||||
: 0
|
/>
|
||||||
}deg`,
|
)}
|
||||||
},
|
<RNImage
|
||||||
],
|
source={(() => {
|
||||||
}}
|
const icon = getShipIcon(
|
||||||
/>
|
alarmData?.level || 0,
|
||||||
|
gpsData.fishing
|
||||||
|
);
|
||||||
|
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
|
||||||
|
return typeof icon === "string" ? { uri: icon } : icon;
|
||||||
|
})()}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
rotate: `${
|
||||||
|
typeof gpsData.h === "number" && !isNaN(gpsData.h)
|
||||||
|
? gpsData.h
|
||||||
|
: 0
|
||||||
|
}deg`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Marker>
|
</Marker>
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
<TouchableOpacity style={styles.button} onPress={drawPolyline}>
|
|
||||||
<Text style={styles.buttonText}>Get GPS Data</Text>
|
<View className="absolute top-14 right-2 shadow-md">
|
||||||
</TouchableOpacity>
|
<SosButton />
|
||||||
</SafeAreaProvider>
|
</View>
|
||||||
|
<GPSInfoPanel gpsData={gpsData!} />
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +442,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
display: "none",
|
// display: "none",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 50,
|
top: 50,
|
||||||
right: 20,
|
right: 20,
|
||||||
@@ -562,20 +464,25 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
titleContainer: {
|
|
||||||
flexDirection: "row",
|
pingContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
justifyContent: "center",
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
stepContainer: {
|
pingCircle: {
|
||||||
gap: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
reactLogo: {
|
|
||||||
height: 178,
|
|
||||||
width: 290,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "#ED3F27",
|
||||||
|
},
|
||||||
|
centerDot: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#0096FF",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,71 @@
|
|||||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import ScanQRCode from "@/components/ScanQRCode";
|
||||||
|
import Select from "@/components/Select";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function Sensor() {
|
export default function Sensor() {
|
||||||
|
const [scanModalVisible, setScanModalVisible] = useState(false);
|
||||||
|
const [scannedData, setScannedData] = useState<string | null>(null);
|
||||||
|
const handleQRCodeScanned = (data: string) => {
|
||||||
|
setScannedData(data);
|
||||||
|
// Alert.alert("QR Code Scanned", `Result: ${data}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanPress = () => {
|
||||||
|
setScanModalVisible(true);
|
||||||
|
};
|
||||||
|
const [selectedValue, setSelectedValue] = useState<
|
||||||
|
string | number | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: "Apple", value: "apple" },
|
||||||
|
{ label: "Banana", value: "banana" },
|
||||||
|
{ label: "Cherry", value: "cherry", disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.titleText}>Cảm biến trên tàu</Text>
|
<Text style={styles.titleText}>Cảm biến trên tàu</Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: "80%", marginBottom: 20 }}
|
||||||
|
options={options}
|
||||||
|
value={selectedValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSelectedValue(val);
|
||||||
|
console.log("Value: ", val);
|
||||||
|
}}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Pressable style={styles.scanButton} onPress={handleScanPress}>
|
||||||
|
<Text style={styles.scanButtonText}>📱 Scan QR Code</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{scannedData && (
|
||||||
|
<View style={styles.resultContainer}>
|
||||||
|
<Text style={styles.resultLabel}>Last Scanned:</Text>
|
||||||
|
<Text style={styles.resultText}>{scannedData}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<ScanQRCode
|
||||||
|
visible={scanModalVisible}
|
||||||
|
onClose={() => setScanModalVisible(false)}
|
||||||
|
onScanned={handleQRCodeScanned}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,11 +82,48 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
lineHeight: 40,
|
lineHeight: 40,
|
||||||
marginBottom: 10,
|
marginBottom: 30,
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
scanButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginVertical: 15,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
scanButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
resultContainer: {
|
||||||
|
marginTop: 30,
|
||||||
|
padding: 15,
|
||||||
|
backgroundColor: "#f0f0f0",
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: "80%",
|
||||||
|
},
|
||||||
|
resultLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
resultText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#333",
|
||||||
|
fontFamily: "Menlo",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet } from "react-native";
|
import { useEffect, useState } from "react";
|
||||||
|
import { StyleSheet, View, ScrollView } 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 { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { TOKEN } from "@/constants";
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { removeStorageItem } from "@/utils/storage";
|
import { removeStorageItem } from "@/utils/storage";
|
||||||
import { useState } from "react";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
type Todo = {
|
type Todo = {
|
||||||
userId: number;
|
userId: number;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -18,54 +23,131 @@ 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 { colors } = useThemeContext();
|
||||||
|
const [isEnabled, setIsEnabled] = useState(locale === "vi");
|
||||||
|
|
||||||
// useEffect(() => {
|
// Sync isEnabled state khi locale thay đổi
|
||||||
// getData();
|
useEffect(() => {
|
||||||
// }, []);
|
setIsEnabled(locale === "vi");
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
const getData = async () => {
|
const toggleSwitch = async () => {
|
||||||
try {
|
const newLocale = isEnabled ? "en" : "vi";
|
||||||
const response = await api.get("/todos/1");
|
await setLocale(newLocale);
|
||||||
setData(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||||
<ThemedText type="title">Settings</ThemedText>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedView
|
<ScrollView
|
||||||
style={styles.button}
|
style={styles.scrollView}
|
||||||
onTouchEnd={() => {
|
contentContainerStyle={styles.scrollContent}
|
||||||
removeStorageItem(TOKEN);
|
showsVerticalScrollIndicator={false}
|
||||||
router.replace("/auth/login");
|
>
|
||||||
}}
|
<ThemedText type="title" style={styles.title}>
|
||||||
>
|
{t("navigation.setting")}
|
||||||
<ThemedText type="defaultSemiBold">Đăng xuất</ThemedText>
|
</ThemedText>
|
||||||
|
|
||||||
|
{/* Theme Toggle Section */}
|
||||||
|
<ThemeToggle style={styles.themeSection} />
|
||||||
|
|
||||||
|
{/* Language Section */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.settingItem,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedText type="default">{t("common.language")}</ThemedText>
|
||||||
|
<RotateSwitch
|
||||||
|
initialValue={isEnabled}
|
||||||
|
onChange={toggleSwitch}
|
||||||
|
size="sm"
|
||||||
|
offImage={EnIcon}
|
||||||
|
onImage={VnIcon}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<ThemedView
|
||||||
|
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||||
|
onTouchEnd={async () => {
|
||||||
|
await removeStorageItem(TOKEN);
|
||||||
|
await removeStorageItem(DOMAIN);
|
||||||
|
router.navigate("/auth/login");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemedText type="defaultSemiBold" style={styles.buttonText}>
|
||||||
|
{t("auth.logout")}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<ThemedView
|
||||||
|
style={[styles.debugSection, { backgroundColor: colors.surface }]}
|
||||||
|
>
|
||||||
|
<ThemedText type="default">{data.title}</ThemedText>
|
||||||
|
<ThemedText type="default">{data.completed}</ThemedText>
|
||||||
|
<ThemedText type="default">{data.id}</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
{data && (
|
</SafeAreaView>
|
||||||
<ThemedView style={{ marginTop: 20 }}>
|
|
||||||
<ThemedText type="default">{data.title}</ThemedText>
|
|
||||||
<ThemedText type="default">{data.completed}</ThemedText>
|
|
||||||
<ThemedText type="default">{data.id}</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
)}
|
|
||||||
</ThemedView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 5,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
},
|
||||||
justifyContent: "center",
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
themeSection: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
padding: 10,
|
paddingVertical: 14,
|
||||||
backgroundColor: "#007AFF",
|
paddingHorizontal: 20,
|
||||||
borderRadius: 5,
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
debugSection: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import CrewListTable from "@/components/tripInfo/CrewListTable";
|
|||||||
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
|
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
|
||||||
import NetListTable from "@/components/tripInfo/NetListTable";
|
import NetListTable from "@/components/tripInfo/NetListTable";
|
||||||
import TripCostTable from "@/components/tripInfo/TripCostTable";
|
import TripCostTable from "@/components/tripInfo/TripCostTable";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function TripInfoScreen() {
|
export default function TripInfoScreen() {
|
||||||
|
const { colors } = useThemeContext();
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.titleText}>Thông Tin Chuyến Đi</Text>
|
<Text style={[styles.titleText, { color: colors.text }]}>
|
||||||
|
Thông Tin Chuyến Đi
|
||||||
|
</Text>
|
||||||
<View style={styles.buttonWrapper}>
|
<View style={styles.buttonWrapper}>
|
||||||
<ButtonCreateNewHaulOrTrip />
|
<ButtonCreateNewHaulOrTrip />
|
||||||
</View>
|
</View>
|
||||||
@@ -34,6 +38,10 @@ export default function TripInfoScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 5,
|
||||||
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
@@ -57,6 +65,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 10,
|
gap: 10,
|
||||||
marginTop: 15,
|
marginTop: 15,
|
||||||
|
marginBottom: 15,
|
||||||
},
|
},
|
||||||
titleText: {
|
titleText: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ import { Stack, useRouter } from "expo-router";
|
|||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import Toast from "react-native-toast-message";
|
// import Toast from "react-native-toast-message";
|
||||||
|
|
||||||
|
// import { toastConfig } from "@/config";
|
||||||
|
import { toastConfig } from "@/config";
|
||||||
import { setRouterInstance } from "@/config/auth";
|
import { setRouterInstance } from "@/config/auth";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
import "@/global.css";
|
||||||
|
import { I18nProvider } from "@/hooks/use-i18n";
|
||||||
|
import { ThemeProvider as AppThemeProvider } from "@/hooks/use-theme-context";
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
import "../global.css";
|
import "../global.css";
|
||||||
export default function RootLayout() {
|
function AppContent() {
|
||||||
const colorScheme = useColorScheme();
|
// const { colorScheme } = useThemeContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
console.log("Color Scheme: ", colorScheme);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRouterInstance(router);
|
setRouterInstance(router);
|
||||||
@@ -48,7 +56,17 @@ export default function RootLayout() {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
<Toast />
|
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<AppThemeProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AppThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
|
import EnIcon from "@/assets/icons/en_icon.png";
|
||||||
|
import VnIcon from "@/assets/icons/vi_icon.png";
|
||||||
|
import RotateSwitch from "@/components/rotate-switch";
|
||||||
|
import ScanQRCode from "@/components/ScanQRCode";
|
||||||
import { ThemedText } from "@/components/themed-text";
|
import { ThemedText } from "@/components/themed-text";
|
||||||
import { ThemedView } from "@/components/themed-view";
|
import { ThemedView } from "@/components/themed-view";
|
||||||
import { showToastError } from "@/config";
|
import SliceSwitch from "@/components/ui/slice-switch";
|
||||||
import { TOKEN } from "@/constants";
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
import { login } from "@/controller/AuthController";
|
import { login } from "@/controller/AuthController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import {
|
||||||
|
ColorScheme as ThemeColorScheme,
|
||||||
|
useTheme,
|
||||||
|
} from "@/hooks/use-theme-context";
|
||||||
|
import { showErrorToast, showWarningToast } from "@/services/toast_service";
|
||||||
import {
|
import {
|
||||||
getStorageItem,
|
getStorageItem,
|
||||||
removeStorageItem,
|
removeStorageItem,
|
||||||
setStorageItem,
|
setStorageItem,
|
||||||
} from "@/utils/storage";
|
} from "@/utils/storage";
|
||||||
import { parseJwtToken } from "@/utils/token";
|
import { parseJwtToken } from "@/utils/token";
|
||||||
|
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
@@ -23,49 +35,100 @@ 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("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isShowingQRScanner, setIsShowingQRScanner] = useState(false);
|
||||||
|
const { t, setLocale, locale } = useI18n();
|
||||||
|
const { colors, colorScheme } = useTheme();
|
||||||
|
const styles = useMemo(
|
||||||
|
() => createStyles(colors, colorScheme),
|
||||||
|
[colors, colorScheme]
|
||||||
|
);
|
||||||
|
const placeholderColor = colors.textSecondary;
|
||||||
|
const buttonTextColor = colorScheme === "dark" ? colors.text : colors.surface;
|
||||||
|
const [isVNLang, setIsVNLang] = useState(false);
|
||||||
|
|
||||||
const checkLogin = useCallback(async () => {
|
const checkLogin = useCallback(async () => {
|
||||||
const token = await getStorageItem(TOKEN);
|
const token = await getStorageItem(TOKEN);
|
||||||
console.log("Token:", token);
|
const domain = await getStorageItem(DOMAIN);
|
||||||
|
// console.log("Token:", token);
|
||||||
|
// removeStorageItem(DOMAIN);
|
||||||
|
console.log("Domain:", domain);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!domain) {
|
||||||
|
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);
|
||||||
const oneHour = 60 * 60;
|
const oneHour = 60 * 60;
|
||||||
if (exp - now < oneHour) {
|
if (exp - now < oneHour) {
|
||||||
await removeStorageItem(TOKEN);
|
await removeStorageItem(TOKEN);
|
||||||
|
await removeStorageItem(DOMAIN);
|
||||||
} else {
|
} else {
|
||||||
router.replace("/(tabs)");
|
router.replace("/(tabs)");
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVNLang(locale === "vi");
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkLogin();
|
checkLogin();
|
||||||
}, [checkLogin]);
|
}, [checkLogin]);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleQRCodeScanned = async (data: string) => {
|
||||||
|
console.log("QR Code Scanned Data:", data);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.username && parsed.password) {
|
||||||
|
// update UI fields
|
||||||
|
setUsername(parsed.username);
|
||||||
|
setPassword(parsed.password);
|
||||||
|
console.log("Domain: ", parsed.device_ip);
|
||||||
|
|
||||||
|
// close scanner so user sees the filled form
|
||||||
|
await setStorageItem(DOMAIN, parsed.device_ip);
|
||||||
|
|
||||||
|
// // call login directly with scanned credentials to avoid waiting for state to update
|
||||||
|
await handleLogin({
|
||||||
|
username: parsed.username,
|
||||||
|
password: parsed.password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showWarningToast("Mã QR không hợp lệ");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showWarningToast("Mã QR không hợp lệ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (creds?: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
|
const user = creds?.username ?? username;
|
||||||
|
const pass = creds?.password ?? password;
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!username.trim() || !password.trim()) {
|
if (!user?.trim() || !pass?.trim()) {
|
||||||
showToastError("Lỗi", "Vui lòng nhập tài khoản và mật khẩu");
|
showErrorToast("Vui lòng nhập tài khoản và mật khẩu");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const body: Model.LoginRequestBody = {
|
const body: Model.LoginRequestBody = {
|
||||||
username: username,
|
username: user,
|
||||||
password: password,
|
password: pass,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await login(body);
|
const response = await login(body);
|
||||||
@@ -81,8 +144,7 @@ export default function LoginScreen() {
|
|||||||
router.replace("/(tabs)");
|
router.replace("/(tabs)");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToastError(
|
showErrorToast(
|
||||||
"Lỗi",
|
|
||||||
error instanceof Error ? error.message : "Đăng nhập thất bại"
|
error instanceof Error ? error.message : "Đăng nhập thất bại"
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -90,12 +152,24 @@ 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"}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
<ScrollView
|
||||||
|
style={{ flex: 1, backgroundColor: colors.background }}
|
||||||
|
contentContainerStyle={styles.scrollContainer}
|
||||||
|
>
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
@@ -106,18 +180,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>
|
|
||||||
{/* Owner Logo */}
|
|
||||||
<View style={styles.ownerContainer}>
|
|
||||||
<Image
|
|
||||||
source={require("@/assets/images/owner.png")}
|
|
||||||
style={styles.ownerLogo}
|
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<ThemedText style={styles.subtitle}>
|
|
||||||
Đăng nhập để tiếp tục
|
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -125,11 +188,11 @@ 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={placeholderColor}
|
||||||
value={username}
|
value={username}
|
||||||
onChangeText={setUsername}
|
onChangeText={setUsername}
|
||||||
editable={!loading}
|
editable={!loading}
|
||||||
@@ -139,144 +202,261 @@ 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>
|
||||||
<TextInput
|
<View className="relative">
|
||||||
style={styles.input}
|
<TextInput
|
||||||
placeholder="Nhập mật khẩu"
|
style={styles.input}
|
||||||
placeholderTextColor="#999"
|
placeholder={t("auth.password_placeholder")}
|
||||||
value={password}
|
placeholderTextColor={placeholderColor}
|
||||||
onChangeText={setPassword}
|
value={password}
|
||||||
secureTextEntry
|
onChangeText={setPassword}
|
||||||
editable={!loading}
|
secureTextEntry={!showPassword}
|
||||||
autoCapitalize="none"
|
editable={!loading}
|
||||||
/>
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{/* Position absolute with top:0 and bottom:0 and justifyContent:center
|
||||||
|
ensures the icon remains vertically centered inside the input */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? "eye-off" : "eye"}
|
||||||
|
size={22}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Login Button */}
|
{/* Login Button (3/4) + QR Scan (1/4) */}
|
||||||
<TouchableOpacity
|
<View
|
||||||
style={[
|
style={{
|
||||||
styles.loginButton,
|
flexDirection: "row",
|
||||||
loading && styles.loginButtonDisabled,
|
alignItems: "center",
|
||||||
]}
|
justifyContent: "center",
|
||||||
onPress={handleLogin}
|
}}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
{loading ? (
|
<TouchableOpacity
|
||||||
<ActivityIndicator color="#fff" size="small" />
|
style={[
|
||||||
) : (
|
styles.loginButton,
|
||||||
<Text style={styles.loginButtonText}>Đăng nhập</Text>
|
loading && styles.loginButtonDisabled,
|
||||||
)}
|
{ flex: 5, marginRight: 12, marginTop: 0 },
|
||||||
</TouchableOpacity>
|
]}
|
||||||
|
onPress={() => handleLogin()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={buttonTextColor} size="small" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[styles.loginButtonText, { color: buttonTextColor }]}
|
||||||
|
>
|
||||||
|
{t("auth.login")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Footer text */}
|
<TouchableOpacity
|
||||||
<View style={styles.footerContainer}>
|
style={{
|
||||||
<ThemedText style={styles.footerText}>
|
flex: 1,
|
||||||
Chưa có tài khoản?{" "}
|
paddingVertical: 10,
|
||||||
<Text style={styles.linkText}>Đăng ký ngay</Text>
|
marginTop: 0,
|
||||||
</ThemedText>
|
borderColor: colors.border,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
onPress={() => setIsShowingQRScanner(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="qr-code-scanner"
|
||||||
|
size={28}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<View style={styles.languageSwitcherContainer}>
|
||||||
|
<RotateSwitch
|
||||||
|
initialValue={isVNLang}
|
||||||
|
onChange={handleSwitchLanguage}
|
||||||
|
size="sm"
|
||||||
|
offImage={EnIcon}
|
||||||
|
onImage={VnIcon}
|
||||||
|
/>
|
||||||
|
<SliceSwitch
|
||||||
|
size="sm"
|
||||||
|
leftIcon="moon"
|
||||||
|
leftIconColor={
|
||||||
|
colorScheme === "dark" ? colors.background : colors.surface
|
||||||
|
}
|
||||||
|
rightIcon="sunny"
|
||||||
|
rightIconColor={
|
||||||
|
colorScheme === "dark" ? colors.warning : "orange"
|
||||||
|
}
|
||||||
|
activeBackgroundColor={colors.text}
|
||||||
|
inactiveBackgroundColor={colors.surface}
|
||||||
|
inactiveOverlayColor={colors.textSecondary}
|
||||||
|
activeOverlayColor={colors.background}
|
||||||
|
/>
|
||||||
</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>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<ScanQRCode
|
||||||
|
visible={isShowingQRScanner}
|
||||||
|
onClose={() => setIsShowingQRScanner(false)}
|
||||||
|
onScanned={handleQRCodeScanned}
|
||||||
|
/>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (colors: typeof Colors.light, scheme: ThemeColorScheme) =>
|
||||||
scrollContainer: {
|
StyleSheet.create({
|
||||||
flexGrow: 1,
|
scrollContainer: {
|
||||||
justifyContent: "center",
|
flexGrow: 1,
|
||||||
},
|
justifyContent: "center",
|
||||||
container: {
|
backgroundColor: colors.background,
|
||||||
flex: 1,
|
},
|
||||||
paddingHorizontal: 20,
|
container: {
|
||||||
justifyContent: "center",
|
flex: 1,
|
||||||
},
|
paddingHorizontal: 20,
|
||||||
headerContainer: {
|
justifyContent: "center",
|
||||||
marginBottom: 40,
|
},
|
||||||
alignItems: "center",
|
headerContainer: {
|
||||||
},
|
marginBottom: 40,
|
||||||
logo: {
|
alignItems: "center",
|
||||||
width: 120,
|
},
|
||||||
height: 120,
|
logo: {
|
||||||
marginBottom: 20,
|
width: 120,
|
||||||
},
|
height: 120,
|
||||||
title: {
|
marginBottom: 20,
|
||||||
fontSize: 28,
|
},
|
||||||
fontWeight: "bold",
|
title: {
|
||||||
marginBottom: 8,
|
fontSize: 28,
|
||||||
},
|
fontWeight: "bold",
|
||||||
subtitle: {
|
marginBottom: 8,
|
||||||
fontSize: 16,
|
},
|
||||||
opacity: 0.7,
|
subtitle: {
|
||||||
},
|
fontSize: 16,
|
||||||
formContainer: {
|
opacity: 0.7,
|
||||||
gap: 16,
|
},
|
||||||
},
|
formContainer: {
|
||||||
inputGroup: {
|
gap: 16,
|
||||||
gap: 8,
|
},
|
||||||
},
|
inputGroup: {
|
||||||
label: {
|
gap: 8,
|
||||||
fontSize: 14,
|
},
|
||||||
fontWeight: "600",
|
label: {
|
||||||
},
|
fontSize: 14,
|
||||||
input: {
|
fontWeight: "600",
|
||||||
borderWidth: 1,
|
},
|
||||||
borderColor: "#ddd",
|
input: {
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
paddingHorizontal: 12,
|
borderColor: colors.border,
|
||||||
paddingVertical: 12,
|
borderRadius: 8,
|
||||||
fontSize: 16,
|
paddingHorizontal: 12,
|
||||||
backgroundColor: "#f5f5f5",
|
paddingVertical: 12,
|
||||||
color: "#000",
|
fontSize: 16,
|
||||||
},
|
backgroundColor: colors.surface,
|
||||||
loginButton: {
|
color: colors.text,
|
||||||
backgroundColor: "#007AFF",
|
},
|
||||||
paddingVertical: 14,
|
loginButton: {
|
||||||
borderRadius: 8,
|
backgroundColor: colors.primary,
|
||||||
alignItems: "center",
|
paddingVertical: 14,
|
||||||
marginTop: 16,
|
borderRadius: 8,
|
||||||
},
|
alignItems: "center",
|
||||||
loginButtonDisabled: {
|
marginTop: 16,
|
||||||
opacity: 0.6,
|
},
|
||||||
},
|
loginButtonDisabled: {
|
||||||
loginButtonText: {
|
opacity: 0.6,
|
||||||
color: "#fff",
|
},
|
||||||
fontSize: 16,
|
loginButtonText: {
|
||||||
fontWeight: "600",
|
fontSize: 16,
|
||||||
},
|
fontWeight: "600",
|
||||||
footerContainer: {
|
},
|
||||||
marginTop: 16,
|
footerContainer: {
|
||||||
alignItems: "center",
|
marginTop: 16,
|
||||||
},
|
alignItems: "center",
|
||||||
footerText: {
|
},
|
||||||
fontSize: 14,
|
footerText: {
|
||||||
},
|
fontSize: 14,
|
||||||
linkText: {
|
},
|
||||||
color: "#007AFF",
|
linkText: {
|
||||||
fontWeight: "600",
|
color: colors.primary,
|
||||||
},
|
fontWeight: "600",
|
||||||
ownerContainer: {
|
},
|
||||||
alignItems: "center",
|
copyrightContainer: {
|
||||||
},
|
marginTop: 20,
|
||||||
ownerLogo: {
|
alignItems: "center",
|
||||||
width: 150,
|
},
|
||||||
height: 50,
|
copyrightText: {
|
||||||
},
|
fontSize: 12,
|
||||||
copyrightContainer: {
|
opacity: 0.6,
|
||||||
marginTop: 20,
|
textAlign: "center",
|
||||||
alignItems: "center",
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
copyrightText: {
|
languageSwitcherContainer: {
|
||||||
fontSize: 12,
|
display: "flex",
|
||||||
opacity: 0.6,
|
flexDirection: "row",
|
||||||
textAlign: "center",
|
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: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
languageButtonActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
languageButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
languageButtonTextActive: {
|
||||||
|
color: scheme === "dark" ? colors.text : colors.surface,
|
||||||
|
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,9 +1,22 @@
|
|||||||
module.exports = function (api) {
|
module.exports = function (api) {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
presets: [
|
presets: [['babel-preset-expo'], 'nativewind/babel'],
|
||||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
|
||||||
"nativewind/babel",
|
plugins: [
|
||||||
|
[
|
||||||
|
'module-resolver',
|
||||||
|
{
|
||||||
|
root: ['./'],
|
||||||
|
|
||||||
|
alias: {
|
||||||
|
'@': './',
|
||||||
|
'tailwind.config': './tailwind.config.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'react-native-worklets/plugin',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
76
components/AlarmList.tsx
Normal file
76
components/AlarmList.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
type AlarmItem = {
|
||||||
|
name: string;
|
||||||
|
t: number;
|
||||||
|
level: number;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlarmProp = {
|
||||||
|
alarmsData: AlarmItem[];
|
||||||
|
onPress?: (alarm: AlarmItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
||||||
|
const sortedAlarmsData = [...alarmsData].sort((a, b) => b.level - a.level);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={sortedAlarmsData}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onPress?.(item)}
|
||||||
|
className="flex flex-row gap-5 p-3 justify-start items-baseline w-full"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`flex-none h-3 w-3 rounded-full ${getBackgroundColorByLevel(
|
||||||
|
item.level
|
||||||
|
)}`}
|
||||||
|
></View>
|
||||||
|
<View className="flex">
|
||||||
|
<Text className={`grow text-lg ${getTextColorByLevel(item.level)}`}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="grow text-md text-gray-400">
|
||||||
|
{formatTimestamp(item.t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackgroundColorByLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 1:
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case 2:
|
||||||
|
return "bg-orange-500";
|
||||||
|
case 3:
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColorByLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 1:
|
||||||
|
return "text-yellow-600";
|
||||||
|
case 2:
|
||||||
|
return "text-orange-600";
|
||||||
|
case 3:
|
||||||
|
return "text-red-600";
|
||||||
|
default:
|
||||||
|
return "text-gray-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: number) => {
|
||||||
|
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlarmList;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,48 +1,75 @@
|
|||||||
|
import { queryGpsData } from "@/controller/DeviceController";
|
||||||
|
import {
|
||||||
|
queryStartNewHaul,
|
||||||
|
queryUpdateTripState,
|
||||||
|
} from "@/controller/TripController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import {
|
||||||
|
showErrorToast,
|
||||||
|
showSuccessToast,
|
||||||
|
showWarningToast,
|
||||||
|
} from "@/services/toast_service";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
import { AntDesign } from "@expo/vector-icons";
|
import { AntDesign } from "@expo/vector-icons";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import { Alert, StyleSheet, View } from "react-native";
|
||||||
|
import IconButton from "./IconButton";
|
||||||
|
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
|
||||||
|
|
||||||
interface StartButtonProps {
|
interface StartButtonProps {
|
||||||
title?: string;
|
gpsData?: Model.GPSResponse;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface a {
|
||||||
|
fishingLogs?: Model.FishingLogInfo[] | null;
|
||||||
|
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
||||||
title = "Bắt đầu mẻ lưới",
|
gpsData,
|
||||||
onPress,
|
onPress,
|
||||||
}) => {
|
}) => {
|
||||||
const [isStarted, setIsStarted] = useState(false);
|
const [isStarted, setIsStarted] = useState(false);
|
||||||
|
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { trip, getTrip } = useTrip();
|
||||||
|
useEffect(() => {
|
||||||
|
getTrip();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkHaulFinished = () => {
|
||||||
|
return trip?.fishing_logs?.some((h) => h.status === 0);
|
||||||
|
};
|
||||||
|
|
||||||
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",
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Kết thúc",
|
|
||||||
onPress: () => {
|
|
||||||
setIsStarted(false);
|
|
||||||
Alert.alert("Thành công", "Đã kết thúc mẻ lưới!");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Alert.alert("Bắt đầu mẻ lưới", "Bạn có muốn bắt đầu mẻ lưới mới?", [
|
|
||||||
{
|
{
|
||||||
text: "Hủy",
|
text: t("trip.cancelButton"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Bắt đầu",
|
text: t("trip.endButton"),
|
||||||
|
onPress: () => {
|
||||||
|
setIsStarted(false);
|
||||||
|
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
||||||
|
{
|
||||||
|
text: t("trip.cancelButton"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("trip.startButton"),
|
||||||
onPress: () => {
|
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"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -53,24 +80,103 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartTrip = async (state: number, note?: string) => {
|
||||||
|
if (trip?.trip_status !== 2) {
|
||||||
|
showWarningToast(t("trip.alreadyStarted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await queryUpdateTripState({
|
||||||
|
status: state,
|
||||||
|
note: note || "",
|
||||||
|
});
|
||||||
|
if (resp.status === 200) {
|
||||||
|
showSuccessToast(t("trip.startTripSuccess"));
|
||||||
|
await getTrip();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error stating trip :", error);
|
||||||
|
showErrorToast("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewHaul = async () => {
|
||||||
|
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
||||||
|
showWarningToast(t("trip.finishCurrentHaul"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!gpsData) {
|
||||||
|
const response = await queryGpsData();
|
||||||
|
gpsData = response.data;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body: Model.NewFishingLogRequest = {
|
||||||
|
trip_id: trip?.id || "",
|
||||||
|
start_at: new Date(),
|
||||||
|
start_lat: gpsData.lat,
|
||||||
|
start_lon: gpsData.lon,
|
||||||
|
weather_description: t("trip.weatherDescription"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await queryStartNewHaul(body);
|
||||||
|
if (resp.status === 200) {
|
||||||
|
showSuccessToast(t("trip.startHaulSuccess"));
|
||||||
|
await getTrip();
|
||||||
|
} else {
|
||||||
|
showErrorToast(t("trip.createHaulFailed"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
// showErrorToast(t("trip.createHaulFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
|
||||||
|
if (trip?.trip_status === 4 || trip?.trip_status === 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View>
|
||||||
style={[styles.button, isStarted && styles.buttonActive]}
|
{trip?.trip_status === 2 ? (
|
||||||
onPress={handlePress}
|
<IconButton
|
||||||
activeOpacity={0.8}
|
icon={<AntDesign name="plus" />}
|
||||||
>
|
type="primary"
|
||||||
<View style={styles.content}>
|
style={{ backgroundColor: "green", borderRadius: 10 }}
|
||||||
<AntDesign
|
onPress={async () => handleStartTrip(3)}
|
||||||
name={isStarted ? "close" : "plus"}
|
>
|
||||||
size={18}
|
{t("trip.startTrip")}
|
||||||
color="#fff"
|
</IconButton>
|
||||||
style={styles.icon}
|
) : checkHaulFinished() ? (
|
||||||
/>
|
<IconButton
|
||||||
<Text style={styles.text}>
|
icon={<AntDesign name="plus" color={"white"} />}
|
||||||
{isStarted ? "Kết thúc mẻ lưới" : title}
|
type="primary"
|
||||||
</Text>
|
style={{ borderRadius: 10 }}
|
||||||
</View>
|
onPress={() => setIsFinishHaulModalOpen(true)}
|
||||||
</TouchableOpacity>
|
>
|
||||||
|
{t("trip.endHaul")}
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon={<AntDesign name="plus" color={"white"} />}
|
||||||
|
type="primary"
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
onPress={async () => {
|
||||||
|
createNewHaul();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("trip.startHaul")}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<CreateOrUpdateHaulModal
|
||||||
|
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
|
||||||
|
fishingLogIndex={trip?.fishing_logs?.length!}
|
||||||
|
isVisible={isFinishHaulModalOpen}
|
||||||
|
onClose={function (): void {
|
||||||
|
setIsFinishHaulModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
159
components/IconButton.tsx
Normal file
159
components/IconButton.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
GestureResponderEvent,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
type ButtonType = "primary" | "default" | "dashed" | "text" | "link" | "danger";
|
||||||
|
type ButtonShape = "default" | "circle" | "round";
|
||||||
|
type ButtonSize = "small" | "middle" | "large";
|
||||||
|
|
||||||
|
export interface IconButtonProps {
|
||||||
|
type?: ButtonType;
|
||||||
|
shape?: ButtonShape;
|
||||||
|
size?: ButtonSize;
|
||||||
|
icon?: React.ReactNode; // render an icon component, e.g. <AntDesign name="plus" />
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress?: (e?: GestureResponderEvent) => void;
|
||||||
|
children?: React.ReactNode; // label text
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
block?: boolean; // full width
|
||||||
|
activeOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IconButton
|
||||||
|
* A lightweight Button component inspired by Ant Design Button API, tuned for React Native.
|
||||||
|
* Accepts an `icon` prop as a React node for maximum flexibility.
|
||||||
|
*/
|
||||||
|
const IconButton: React.FC<IconButtonProps> = ({
|
||||||
|
type = "default",
|
||||||
|
shape = "default",
|
||||||
|
size = "middle",
|
||||||
|
icon,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
block = false,
|
||||||
|
activeOpacity = 0.8,
|
||||||
|
}) => {
|
||||||
|
const sizeMap = {
|
||||||
|
small: { height: 32, fontSize: 14, paddingHorizontal: 10 },
|
||||||
|
middle: { height: 40, fontSize: 16, paddingHorizontal: 14 },
|
||||||
|
large: { height: 48, fontSize: 18, paddingHorizontal: 18 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const colors: Record<
|
||||||
|
ButtonType,
|
||||||
|
{ backgroundColor?: string; textColor: string; borderColor?: string }
|
||||||
|
> = {
|
||||||
|
primary: { backgroundColor: "#4ecdc4", textColor: "#fff" },
|
||||||
|
default: {
|
||||||
|
backgroundColor: "#f2f2f2",
|
||||||
|
textColor: "#111",
|
||||||
|
borderColor: "#e6e6e6",
|
||||||
|
},
|
||||||
|
dashed: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
textColor: "#111",
|
||||||
|
borderColor: "#d9d9d9",
|
||||||
|
},
|
||||||
|
text: { backgroundColor: "transparent", textColor: "#111" },
|
||||||
|
link: { backgroundColor: "transparent", textColor: "#4ecdc4" },
|
||||||
|
danger: { backgroundColor: "#e74c3c", textColor: "#fff" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sz = sizeMap[size];
|
||||||
|
const color = colors[type];
|
||||||
|
|
||||||
|
const isCircle = shape === "circle";
|
||||||
|
const isRound = shape === "round";
|
||||||
|
|
||||||
|
const handlePress = (e: GestureResponderEvent) => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
onPress?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={activeOpacity}
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{
|
||||||
|
height: sz.height,
|
||||||
|
paddingHorizontal: isCircle ? 0 : sz.paddingHorizontal,
|
||||||
|
backgroundColor: color.backgroundColor ?? "transparent",
|
||||||
|
borderColor: color.borderColor ?? "transparent",
|
||||||
|
borderWidth: type === "dashed" ? 1 : color.borderColor ? 1 : 0,
|
||||||
|
width: isCircle ? sz.height : block ? "100%" : undefined,
|
||||||
|
borderRadius: isCircle ? sz.height / 2 : isRound ? 999 : 8,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
},
|
||||||
|
type === "dashed" ? { borderStyle: "dashed" } : null,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size={"small"}
|
||||||
|
color={color.textColor}
|
||||||
|
style={styles.iconContainer}
|
||||||
|
/>
|
||||||
|
) : icon ? (
|
||||||
|
<View style={styles.iconContainer}>{icon}</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{children ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
{
|
||||||
|
color: color.textColor,
|
||||||
|
fontSize: sz.fontSize,
|
||||||
|
marginLeft: icon || loading ? 6 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 8,
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IconButton;
|
||||||
239
components/ScanQRCode.tsx
Normal file
239
components/ScanQRCode.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface ScanQRCodeProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onScanned: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScanQRCode({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onScanned,
|
||||||
|
}: ScanQRCodeProps) {
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
const cameraRef = useRef(null);
|
||||||
|
// Dùng ref để chặn quét nhiều lần trong cùng một frame/event loop
|
||||||
|
const hasScannedRef = useRef(false);
|
||||||
|
|
||||||
|
// Request camera permission when component mounts or when visible changes to true
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && !permission?.granted) {
|
||||||
|
requestPermission();
|
||||||
|
}
|
||||||
|
}, [visible, permission, requestPermission]);
|
||||||
|
|
||||||
|
// Reset scanned state when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setScanned(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Mỗi khi reset scanned state thì reset luôn ref guard
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scanned) {
|
||||||
|
hasScannedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [scanned]);
|
||||||
|
|
||||||
|
const handleBarCodeScanned = ({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
}) => {
|
||||||
|
// Nếu đã scan rồi, bỏ qua
|
||||||
|
if (hasScannedRef.current || scanned) return;
|
||||||
|
hasScannedRef.current = true;
|
||||||
|
setScanned(true);
|
||||||
|
onScanned(data);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide">
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#0000ff" />
|
||||||
|
<Text style={styles.loadingText}>
|
||||||
|
Requesting camera permission...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide">
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.permissionContainer}>
|
||||||
|
<Text style={styles.permissionTitle}>
|
||||||
|
Camera Permission Required
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.permissionText}>
|
||||||
|
This app needs camera access to scan QR codes. Please allow camera
|
||||||
|
access in your settings.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.button}
|
||||||
|
onPress={() => requestPermission()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Request Permission</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.button, styles.cancelButton]}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide">
|
||||||
|
<CameraView
|
||||||
|
ref={cameraRef}
|
||||||
|
style={styles.camera}
|
||||||
|
// Chỉ gắn handler khi chưa scan để ngắt lắng nghe ngay lập tức sau khi quét thành công
|
||||||
|
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||||
|
barcodeScannerSettings={{
|
||||||
|
barcodeTypes: ["qr"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.unfocusedContainer} />
|
||||||
|
<View style={styles.focusedRow}>
|
||||||
|
<View style={styles.focusedContainer} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.unfocusedContainer} />
|
||||||
|
|
||||||
|
<View style={styles.bottomContainer}>
|
||||||
|
<Text style={styles.scanningText}>
|
||||||
|
{/* Align QR code within the frame */}
|
||||||
|
Đặt mã QR vào khung hình
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.closeButton} onPress={onClose}>
|
||||||
|
<Text style={styles.closeButtonText}>✕ Đóng</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CameraView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
permissionContainer: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
marginHorizontal: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 15,
|
||||||
|
},
|
||||||
|
permissionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#666",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
borderRadius: 8,
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#666",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
unfocusedContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
focusedRow: {
|
||||||
|
height: "80%",
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
focusedContainer: {
|
||||||
|
aspectRatio: 1,
|
||||||
|
width: "70%",
|
||||||
|
borderColor: "#00ff00",
|
||||||
|
borderWidth: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
bottomContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
scanningText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
274
components/Select.tsx
Normal file
274
components/Select.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { AntDesign } from "@expo/vector-icons";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps {
|
||||||
|
value?: string | number;
|
||||||
|
defaultValue?: string | number;
|
||||||
|
options: SelectOption[];
|
||||||
|
onChange?: (value: string | number | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
allowClear?: boolean;
|
||||||
|
showSearch?: boolean;
|
||||||
|
mode?: "single" | "multiple"; // multiple not implemented yet
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
size?: "small" | "middle" | "large";
|
||||||
|
listStyle?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select
|
||||||
|
* A Select component inspired by Ant Design, adapted for React Native.
|
||||||
|
* Supports single selection, search, clear, loading, disabled states.
|
||||||
|
*/
|
||||||
|
const Select: React.FC<SelectProps> = ({
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select an option",
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
allowClear = false,
|
||||||
|
showSearch = false,
|
||||||
|
mode = "single",
|
||||||
|
style,
|
||||||
|
listStyle,
|
||||||
|
size = "middle",
|
||||||
|
}) => {
|
||||||
|
const [selectedValue, setSelectedValue] = useState<
|
||||||
|
string | number | undefined
|
||||||
|
>(value ?? defaultValue);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const filteredOptions = showSearch
|
||||||
|
? options.filter((opt) =>
|
||||||
|
opt.label.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
)
|
||||||
|
: options;
|
||||||
|
|
||||||
|
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||||
|
|
||||||
|
const handleSelect = (val: string | number) => {
|
||||||
|
setSelectedValue(val);
|
||||||
|
onChange?.(val);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setSelectedValue(undefined);
|
||||||
|
onChange?.(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
small: { height: 32, fontSize: 14, paddingHorizontal: 10 },
|
||||||
|
middle: { height: 40, fontSize: 16, paddingHorizontal: 14 },
|
||||||
|
large: { height: 48, fontSize: 18, paddingHorizontal: 18 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sz = sizeMap[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
height: sz.height,
|
||||||
|
paddingHorizontal: sz.paddingHorizontal,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onPress={() => !disabled && !loading && setIsOpen(!isOpen)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onLayout={(e) => setContainerHeight(e.nativeEvent.layout.height)}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color="#4ecdc4" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
{
|
||||||
|
fontSize: sz.fontSize,
|
||||||
|
color: selectedValue ? "#111" : "#999",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{selectedOption?.label || placeholder}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.suffix}>
|
||||||
|
{allowClear && selectedValue && !loading ? (
|
||||||
|
<TouchableOpacity onPress={handleClear} style={styles.icon}>
|
||||||
|
<AntDesign name="close" size={16} color="#999" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
<AntDesign
|
||||||
|
name={isOpen ? "up" : "down"}
|
||||||
|
size={14}
|
||||||
|
color="#999"
|
||||||
|
style={styles.arrow}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<View style={[styles.dropdown, { top: containerHeight }]}>
|
||||||
|
{showSearch && (
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchText}
|
||||||
|
onChangeText={setSearchText}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ScrollView style={[styles.list, listStyle]}>
|
||||||
|
{filteredOptions.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.value}
|
||||||
|
style={[
|
||||||
|
styles.option,
|
||||||
|
item.disabled && styles.optionDisabled,
|
||||||
|
selectedValue === item.value && styles.optionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => !item.disabled && handleSelect(item.value)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.optionText,
|
||||||
|
item.disabled && styles.optionTextDisabled,
|
||||||
|
selectedValue === item.value && styles.optionTextSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{selectedValue === item.value && (
|
||||||
|
<AntDesign name="check" size={16} color="#4ecdc4" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e6e6e6",
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: "#111",
|
||||||
|
},
|
||||||
|
suffix: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e6e6e6",
|
||||||
|
borderTopWidth: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderBottomLeftRadius: 8,
|
||||||
|
borderBottomRightRadius: 8,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 5,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e6e6e6",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 8,
|
||||||
|
margin: 8,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
maxHeight: 200,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
padding: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
optionDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
optionSelected: {
|
||||||
|
backgroundColor: "#f6ffed",
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#111",
|
||||||
|
},
|
||||||
|
optionTextDisabled: {
|
||||||
|
color: "#999",
|
||||||
|
},
|
||||||
|
optionTextSelected: {
|
||||||
|
color: "#4ecdc4",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Select;
|
||||||
17
components/map/Description.tsx
Normal file
17
components/map/Description.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
interface DescriptionProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
export const Description = ({
|
||||||
|
title = "",
|
||||||
|
description = "",
|
||||||
|
}: DescriptionProps) => {
|
||||||
|
return (
|
||||||
|
<View className="flex-row gap-2 ">
|
||||||
|
<Text className="opacity-50 text-lg">{title}:</Text>
|
||||||
|
<Text className="text-lg">{description}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
132
components/map/GPSInfoPanel.tsx
Normal file
132
components/map/GPSInfoPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { convertToDMS, kmhToKnot } from "@/utils/geom";
|
||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Animated, TouchableOpacity, View } from "react-native";
|
||||||
|
import ButtonCreateNewHaulOrTrip from "../ButtonCreateNewHaulOrTrip";
|
||||||
|
import { Description } from "./Description";
|
||||||
|
type GPSInfoPanelProps = {
|
||||||
|
gpsData: Model.GPSResponse | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [panelHeight, setPanelHeight] = useState(0);
|
||||||
|
const translateY = useRef(new Animated.Value(0)).current;
|
||||||
|
const blockBottom = useRef(new Animated.Value(0)).current;
|
||||||
|
const { t } = useI18n();
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: isExpanded ? 0 : 200, // Dịch chuyển xuống 200px khi thu gọn
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetBottom = isExpanded ? panelHeight + 12 : 10;
|
||||||
|
Animated.timing(blockBottom, {
|
||||||
|
toValue: targetBottom,
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, [isExpanded, panelHeight, blockBottom]);
|
||||||
|
|
||||||
|
const togglePanel = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Khối hình vuông */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: blockBottom,
|
||||||
|
left: 5,
|
||||||
|
// width: 48,
|
||||||
|
// height: 48,
|
||||||
|
// backgroundColor: "blue",
|
||||||
|
borderRadius: 4,
|
||||||
|
zIndex: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonCreateNewHaulOrTrip gpsData={gpsData} />
|
||||||
|
{/* <TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// showInfoToast("oad");
|
||||||
|
showWarningToast("This is a warning toast!");
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 z-10 bg-white rounded-full p-1"
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isExpanded ? "close" : "close"}
|
||||||
|
size={20}
|
||||||
|
color="#666"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity> */}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ translateY }],
|
||||||
|
}}
|
||||||
|
className="absolute bottom-0 gap-3 right-0 p-3 left-0 h-auto w-full rounded-t-xl bg-white shadow-md"
|
||||||
|
onLayout={(event) => setPanelHeight(event.nativeEvent.layout.height)}
|
||||||
|
>
|
||||||
|
{/* Nút toggle ở top-right */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={togglePanel}
|
||||||
|
className="absolute top-2 right-2 z-10 bg-white rounded-full p-1"
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isExpanded ? "close" : "close"}
|
||||||
|
size={20}
|
||||||
|
color="#666"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.latitude")}
|
||||||
|
description={convertToDMS(gpsData?.lat ?? 0, true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.longitude")}
|
||||||
|
description={convertToDMS(gpsData?.lon ?? 0, false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.speed")}
|
||||||
|
description={`${kmhToKnot(gpsData?.s ?? 0).toString()} knot`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.heading")}
|
||||||
|
description={`${gpsData?.h ?? 0}°`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Nút floating để mở lại panel khi thu gọn */}
|
||||||
|
{!isExpanded && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={togglePanel}
|
||||||
|
className="absolute bottom-5 right-2 z-20 bg-white rounded-full p-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<MaterialIcons name="info-outline" size={24} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GPSInfoPanel;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { ANDROID_PLATFORM } from "@/constants";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
import { getPolygonCenter } from "@/utils/polyline";
|
import { getPolygonCenter } from "@/utils/polyline";
|
||||||
import React, { memo } from "react";
|
import React, { useRef } from "react";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
import { Marker, Polygon } from "react-native-maps";
|
import { MapMarker, Marker, Polygon } from "react-native-maps";
|
||||||
|
|
||||||
export interface PolygonWithLabelProps {
|
export interface PolygonWithLabelProps {
|
||||||
coordinates: {
|
coordinates: {
|
||||||
@@ -20,7 +22,7 @@ export interface PolygonWithLabelProps {
|
|||||||
/**
|
/**
|
||||||
* Component render Polygon kèm Label/Text ở giữa
|
* Component render Polygon kèm Label/Text ở giữa
|
||||||
*/
|
*/
|
||||||
const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
|
||||||
coordinates,
|
coordinates,
|
||||||
label,
|
label,
|
||||||
content,
|
content,
|
||||||
@@ -33,6 +35,8 @@ const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
|||||||
if (!coordinates || coordinates.length < 3) {
|
if (!coordinates || coordinates.length < 3) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const platform = usePlatform();
|
||||||
|
const markerRef = useRef<MapMarker>(null);
|
||||||
|
|
||||||
const centerPoint = getPolygonCenter(coordinates);
|
const centerPoint = getPolygonCenter(coordinates);
|
||||||
|
|
||||||
@@ -47,12 +51,10 @@ const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
|||||||
|
|
||||||
const labelFontSize = calculateFontSize(12);
|
const labelFontSize = calculateFontSize(12);
|
||||||
const contentFontSize = calculateFontSize(10);
|
const contentFontSize = calculateFontSize(10);
|
||||||
console.log("zoom level: ", zoomLevel);
|
// console.log("zoom level: ", zoomLevel);
|
||||||
|
|
||||||
const paddingScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.2), 0.5);
|
const paddingScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.2), 0.5);
|
||||||
const minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
|
const minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
|
||||||
console.log("Min Width Scale: ", minWidthScale);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Polygon
|
<Polygon
|
||||||
@@ -64,15 +66,17 @@ const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
|||||||
/>
|
/>
|
||||||
{label && (
|
{label && (
|
||||||
<Marker
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
coordinate={centerPoint}
|
coordinate={centerPoint}
|
||||||
zIndex={200}
|
zIndex={50}
|
||||||
tracksViewChanges={false}
|
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||||
anchor={{ x: 0.5, y: 0.5 }}
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
title={platform === ANDROID_PLATFORM ? label : undefined}
|
||||||
|
description={platform === ANDROID_PLATFORM ? content : undefined}
|
||||||
>
|
>
|
||||||
<View style={styles.markerContainer}>
|
<View style={styles.markerContainer}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.labelContainer,
|
|
||||||
{
|
{
|
||||||
paddingHorizontal: 5 * paddingScale,
|
paddingHorizontal: 5 * paddingScale,
|
||||||
paddingVertical: 5 * paddingScale,
|
paddingVertical: 5 * paddingScale,
|
||||||
@@ -142,24 +146,3 @@ const styles = StyleSheet.create({
|
|||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export memoized component để tránh re-render không cần thiết
|
|
||||||
export const PolygonWithLabel = memo(
|
|
||||||
PolygonWithLabelComponent,
|
|
||||||
(prev, next) => {
|
|
||||||
// Custom comparison: chỉ re-render khi coordinates, label, content hoặc zoomLevel thay đổi
|
|
||||||
return (
|
|
||||||
prev.coordinates.length === next.coordinates.length &&
|
|
||||||
prev.coordinates.every(
|
|
||||||
(coord, index) =>
|
|
||||||
coord.latitude === next.coordinates[index]?.latitude &&
|
|
||||||
coord.longitude === next.coordinates[index]?.longitude
|
|
||||||
) &&
|
|
||||||
prev.label === next.label &&
|
|
||||||
prev.content === next.content &&
|
|
||||||
prev.zoomLevel === next.zoomLevel &&
|
|
||||||
prev.fillColor === next.fillColor &&
|
|
||||||
prev.strokeColor === next.strokeColor
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { ANDROID_PLATFORM } from "@/constants";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
import {
|
import {
|
||||||
calculateTotalDistance,
|
calculateTotalDistance,
|
||||||
getMiddlePointOfPolyline,
|
getMiddlePointOfPolyline,
|
||||||
} from "@/utils/polyline";
|
} from "@/utils/polyline";
|
||||||
import React, { memo } from "react";
|
import React, { useRef } from "react";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
import { Marker, Polyline } from "react-native-maps";
|
import { MapMarker, Marker, Polyline } from "react-native-maps";
|
||||||
|
|
||||||
export interface PolylineWithLabelProps {
|
export interface PolylineWithLabelProps {
|
||||||
coordinates: {
|
coordinates: {
|
||||||
@@ -12,6 +14,7 @@ export interface PolylineWithLabelProps {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
}[];
|
}[];
|
||||||
label?: string;
|
label?: string;
|
||||||
|
content?: string;
|
||||||
strokeColor?: string;
|
strokeColor?: string;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
showDistance?: boolean;
|
showDistance?: boolean;
|
||||||
@@ -21,9 +24,10 @@ export interface PolylineWithLabelProps {
|
|||||||
/**
|
/**
|
||||||
* Component render Polyline kèm Label/Text ở giữa
|
* Component render Polyline kèm Label/Text ở giữa
|
||||||
*/
|
*/
|
||||||
const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({
|
export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
|
||||||
coordinates,
|
coordinates,
|
||||||
label,
|
label,
|
||||||
|
content,
|
||||||
strokeColor = "#FF5733",
|
strokeColor = "#FF5733",
|
||||||
strokeWidth = 4,
|
strokeWidth = 4,
|
||||||
showDistance = false,
|
showDistance = false,
|
||||||
@@ -35,14 +39,14 @@ const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({
|
|||||||
|
|
||||||
const middlePoint = getMiddlePointOfPolyline(coordinates);
|
const middlePoint = getMiddlePointOfPolyline(coordinates);
|
||||||
const distance = calculateTotalDistance(coordinates);
|
const distance = calculateTotalDistance(coordinates);
|
||||||
|
const platform = usePlatform();
|
||||||
|
const markerRef = useRef<MapMarker>(null);
|
||||||
let displayText = label || "";
|
let displayText = label || "";
|
||||||
if (showDistance) {
|
if (showDistance) {
|
||||||
displayText += displayText
|
displayText += displayText
|
||||||
? ` (${distance.toFixed(2)}km)`
|
? ` (${distance.toFixed(2)}km)`
|
||||||
: `${distance.toFixed(2)}km`;
|
: `${distance.toFixed(2)}km`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Polyline
|
<Polyline
|
||||||
@@ -53,10 +57,13 @@ const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({
|
|||||||
/>
|
/>
|
||||||
{displayText && (
|
{displayText && (
|
||||||
<Marker
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
coordinate={middlePoint}
|
coordinate={middlePoint}
|
||||||
zIndex={zIndex + 10}
|
zIndex={zIndex + 10}
|
||||||
tracksViewChanges={false}
|
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||||
anchor={{ x: 0.5, y: 0.5 }}
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
title={platform === ANDROID_PLATFORM ? label : undefined}
|
||||||
|
description={platform === ANDROID_PLATFORM ? content : undefined}
|
||||||
>
|
>
|
||||||
<View style={styles.markerContainer}>
|
<View style={styles.markerContainer}>
|
||||||
<View style={styles.labelContainer}>
|
<View style={styles.labelContainer}>
|
||||||
@@ -103,22 +110,3 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export memoized component để tránh re-render không cần thiết
|
|
||||||
export const PolylineWithLabel = memo(
|
|
||||||
PolylineWithLabelComponent,
|
|
||||||
(prev, next) => {
|
|
||||||
// Custom comparison: chỉ re-render khi coordinates, label hoặc showDistance thay đổi
|
|
||||||
return (
|
|
||||||
prev.coordinates.length === next.coordinates.length &&
|
|
||||||
prev.coordinates.every(
|
|
||||||
(coord, index) =>
|
|
||||||
coord.latitude === next.coordinates[index]?.latitude &&
|
|
||||||
coord.longitude === next.coordinates[index]?.longitude
|
|
||||||
) &&
|
|
||||||
prev.label === next.label &&
|
|
||||||
prev.showDistance === next.showDistance &&
|
|
||||||
prev.strokeColor === next.strokeColor
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
237
components/map/SosButton.tsx
Normal file
237
components/map/SosButton.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
queryDeleteSos,
|
||||||
|
queryGetSos,
|
||||||
|
querySendSosMessage,
|
||||||
|
} from "@/controller/DeviceController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { showErrorToast } from "@/services/toast_service";
|
||||||
|
import { sosMessage } from "@/utils/sosUtils";
|
||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import IconButton from "../IconButton";
|
||||||
|
import Select from "../Select";
|
||||||
|
import Modal from "../ui/modal";
|
||||||
|
|
||||||
|
const SosButton = () => {
|
||||||
|
const [sosData, setSosData] = useState<Model.SosResponse | null>();
|
||||||
|
const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
|
||||||
|
const [selectedSosMessage, setSelectedSosMessage] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [customMessage, setCustomMessage] = useState("");
|
||||||
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
const { t } = useI18n();
|
||||||
|
const sosOptions = [
|
||||||
|
...sosMessage.map((msg) => ({
|
||||||
|
ma: msg.ma,
|
||||||
|
moTa: msg.moTa,
|
||||||
|
label: msg.moTa,
|
||||||
|
value: msg.ma,
|
||||||
|
})),
|
||||||
|
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSosData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await queryGetSos();
|
||||||
|
// console.log("SoS ResponseL: ", response);
|
||||||
|
|
||||||
|
setSosData(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch SOS data:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSosData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors: { [key: string]: string } = {};
|
||||||
|
|
||||||
|
if (selectedSosMessage === 999 && customMessage.trim() === "") {
|
||||||
|
newErrors.customMessage = t("home.sos.statusRequired");
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSos = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
console.log("Form chưa validate");
|
||||||
|
return; // Không đóng modal nếu validate fail
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageToSend = "";
|
||||||
|
if (selectedSosMessage === 999) {
|
||||||
|
messageToSend = customMessage.trim();
|
||||||
|
} else {
|
||||||
|
const selectedOption = sosOptions.find(
|
||||||
|
(opt) => opt.ma === selectedSosMessage
|
||||||
|
);
|
||||||
|
messageToSend = selectedOption ? selectedOption.moTa : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gửi dữ liệu đi
|
||||||
|
await sendSosMessage(messageToSend);
|
||||||
|
|
||||||
|
// Đóng modal và reset form sau khi gửi thành công
|
||||||
|
setShowConfirmSosDialog(false);
|
||||||
|
setSelectedSosMessage(null);
|
||||||
|
setCustomMessage("");
|
||||||
|
setErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickButton = async (isActive: boolean) => {
|
||||||
|
console.log("Is Active: ", isActive);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
const resp = await queryDeleteSos();
|
||||||
|
if (resp.status === 200) {
|
||||||
|
await getSosData();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedSosMessage(11); // Mặc định chọn lý do ma: 11
|
||||||
|
setShowConfirmSosDialog(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendSosMessage = async (message: string) => {
|
||||||
|
try {
|
||||||
|
const resp = await querySendSosMessage(message);
|
||||||
|
if (resp.status === 200) {
|
||||||
|
await getSosData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when send sos: ", error);
|
||||||
|
showErrorToast(t("home.sos.sendError"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
icon={<MaterialIcons name="warning" size={20} color="white" />}
|
||||||
|
type="danger"
|
||||||
|
size="middle"
|
||||||
|
onPress={() => handleClickButton(sosData?.active || false)}
|
||||||
|
style={{ borderRadius: 20 }}
|
||||||
|
>
|
||||||
|
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
|
||||||
|
</IconButton>
|
||||||
|
<Modal
|
||||||
|
open={showConfirmSosDialog}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowConfirmSosDialog(false);
|
||||||
|
setSelectedSosMessage(null);
|
||||||
|
setCustomMessage("");
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
okText={t("home.sos.confirm")}
|
||||||
|
cancelText={t("home.sos.cancel")}
|
||||||
|
title={t("home.sos.title")}
|
||||||
|
centered
|
||||||
|
onOk={handleConfirmSos}
|
||||||
|
>
|
||||||
|
{/* Select Nội dung SOS */}
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>{t("home.sos.content")}</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedSosMessage ?? undefined}
|
||||||
|
options={sosOptions}
|
||||||
|
placeholder={t("home.sos.selectReason")}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedSosMessage(value as number);
|
||||||
|
// Clear custom message nếu chọn khác lý do
|
||||||
|
if (value !== 999) {
|
||||||
|
setCustomMessage("");
|
||||||
|
}
|
||||||
|
// Clear error if exists
|
||||||
|
if (errors.sosMessage) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors.sosMessage;
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showSearch={false}
|
||||||
|
style={[errors.sosMessage ? styles.errorBorder : undefined]}
|
||||||
|
/>
|
||||||
|
{errors.sosMessage && (
|
||||||
|
<Text style={styles.errorText}>{errors.sosMessage}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Input Custom Message nếu chọn "Khác" */}
|
||||||
|
{selectedSosMessage === 999 && (
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
errors.customMessage ? styles.errorInput : {},
|
||||||
|
]}
|
||||||
|
placeholder={t("home.sos.enterStatus")}
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
value={customMessage}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setCustomMessage(text);
|
||||||
|
if (text.trim() !== "") {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors.customMessage;
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
/>
|
||||||
|
{errors.customMessage && (
|
||||||
|
<Text style={styles.errorText}>{errors.customMessage}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
formGroup: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
errorBorder: {
|
||||||
|
borderColor: "#ff4444",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ddd",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#333",
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
errorInput: {
|
||||||
|
borderColor: "#ff4444",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ff4444",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SosButton;
|
||||||
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;
|
||||||
154
components/theme-example.tsx
Normal file
154
components/theme-example.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Example component demonstrating theme usage
|
||||||
|
* Shows different ways to use the theme system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
export function ThemeExampleComponent() {
|
||||||
|
const { colors, styles, utils } = useAppTheme();
|
||||||
|
|
||||||
|
// Example of using useThemeColor hook
|
||||||
|
const customTextColor = useThemeColor({}, "textSecondary");
|
||||||
|
const customBackgroundColor = useThemeColor({}, "surfaceSecondary");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
<ThemedView style={styles.surface}>
|
||||||
|
<ThemedText type="title">Theme Examples</ThemedText>
|
||||||
|
|
||||||
|
{/* Using themed components */}
|
||||||
|
<ThemedText type="subtitle">Themed Components</ThemedText>
|
||||||
|
<ThemedView style={styles.card}>
|
||||||
|
<ThemedText>This is a themed text</ThemedText>
|
||||||
|
<ThemedText type="defaultSemiBold">
|
||||||
|
This is bold themed text
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
{/* Using theme colors directly */}
|
||||||
|
<ThemedText type="subtitle">Direct Color Usage</ThemedText>
|
||||||
|
<View
|
||||||
|
style={[styles.card, { borderColor: colors.primary, borderWidth: 2 }]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 16 }}>
|
||||||
|
Using colors.text directly
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ color: colors.primary, fontSize: 14, fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
Primary color text
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Using pre-styled components */}
|
||||||
|
<ThemedText type="subtitle">Pre-styled Components</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<TouchableOpacity style={styles.primaryButton}>
|
||||||
|
<Text style={styles.primaryButtonText}>Primary Button</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.secondaryButton}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Secondary Button</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status containers */}
|
||||||
|
<ThemedText type="subtitle">Status Indicators</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.successContainer}>
|
||||||
|
<Text style={{ color: colors.success, fontWeight: "600" }}>
|
||||||
|
Success Message
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.warningContainer}>
|
||||||
|
<Text style={{ color: colors.warning, fontWeight: "600" }}>
|
||||||
|
Warning Message
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={{ color: colors.error, fontWeight: "600" }}>
|
||||||
|
Error Message
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Using opacity colors */}
|
||||||
|
<ThemedText type="subtitle">Opacity Colors</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.surface,
|
||||||
|
{ backgroundColor: utils.getOpacityColor("primary", 0.1) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.primary }}>
|
||||||
|
Primary with 10% opacity background
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.surface,
|
||||||
|
{ backgroundColor: utils.getOpacityColor("error", 0.2) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.error }}>
|
||||||
|
Error with 20% opacity background
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Theme utilities */}
|
||||||
|
<ThemedText type="subtitle">Theme Utilities</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Is Dark Mode: {utils.isDark ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Is Light Mode: {utils.isLight ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.primaryButton, { marginTop: 10 }]}
|
||||||
|
onPress={utils.toggleTheme}
|
||||||
|
>
|
||||||
|
<Text style={styles.primaryButtonText}>
|
||||||
|
Toggle Theme (Light/Dark)
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Custom themed component example */}
|
||||||
|
<ThemedText type="subtitle">Custom Component</ThemedText>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: customBackgroundColor,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: customTextColor,
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Custom component using useThemeColor
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
components/theme-toggle.tsx
Normal file
109
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Theme Toggle Component for switching between light, dark, and system themes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
style?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ style }: ThemeToggleProps) {
|
||||||
|
const { themeMode, setThemeMode, colors } = useThemeContext();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{
|
||||||
|
mode: "light" as const,
|
||||||
|
label: t("common.theme_light"),
|
||||||
|
icon: "sunny-outline" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "dark" as const,
|
||||||
|
label: t("common.theme_dark"),
|
||||||
|
icon: "moon-outline" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "system" as const,
|
||||||
|
label: t("common.theme_system"),
|
||||||
|
icon: "phone-portrait-outline" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.container, style, { backgroundColor: colors.surface }]}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.title}>{t("common.theme")}</ThemedText>
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
{themeOptions.map((option) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.mode}
|
||||||
|
style={[
|
||||||
|
styles.option,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
themeMode === option.mode
|
||||||
|
? colors.primary
|
||||||
|
: colors.backgroundSecondary,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => setThemeMode(option.mode)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={option.icon}
|
||||||
|
size={20}
|
||||||
|
color={themeMode === option.mode ? "#fff" : colors.icon}
|
||||||
|
/>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.optionText,
|
||||||
|
{ color: themeMode === option.mode ? "#fff" : colors.text },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,42 +1,30 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import React, { useRef, useState } from "react";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
import styles from "./style/CrewListTable.styles";
|
import CrewDetailModal from "./modal/CrewDetailModal";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
// ---------------------------
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
// 🧩 Interface
|
import { createTableStyles } from "./ThemedTable";
|
||||||
// ---------------------------
|
|
||||||
interface CrewMember {
|
|
||||||
id: string;
|
|
||||||
maDinhDanh: string;
|
|
||||||
ten: string;
|
|
||||||
chucVu: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// ⚓ Dữ liệu mẫu
|
|
||||||
// ---------------------------
|
|
||||||
const data: CrewMember[] = [
|
|
||||||
{
|
|
||||||
id: "10",
|
|
||||||
maDinhDanh: "ChuTau",
|
|
||||||
ten: "Nguyễn Nhật Minh",
|
|
||||||
chucVu: "Chủ tàu",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
maDinhDanh: "TV001",
|
|
||||||
ten: "Nguyễn Văn A",
|
|
||||||
chucVu: "Thuyền trưởng",
|
|
||||||
},
|
|
||||||
{ id: "2", maDinhDanh: "TV002", ten: "Trần Văn B", chucVu: "Máy trưởng" },
|
|
||||||
{ id: "3", maDinhDanh: "TV003", ten: "Lê Văn C", chucVu: "Thủy thủ" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const CrewListTable: React.FC = () => {
|
const CrewListTable: 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 [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedCrew, setSelectedCrew] = useState<Model.TripCrews | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
|
||||||
|
const data: Model.TripCrews[] = trip?.crews ?? [];
|
||||||
|
|
||||||
const tongThanhVien = data.length;
|
const tongThanhVien = data.length;
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
@@ -50,7 +38,16 @@ const CrewListTable: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCrewPress = (crewId: string) => {
|
const handleCrewPress = (crewId: string) => {
|
||||||
console.log("Crew ID:", crewId);
|
const crew = data.find((item) => item.Person.personal_id === crewId);
|
||||||
|
if (crew) {
|
||||||
|
setSelectedCrew(crew);
|
||||||
|
setModalVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setSelectedCrew(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,14 +58,14 @@ 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>
|
||||||
)}
|
)}
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={16}
|
size={16}
|
||||||
color="#000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -84,38 +81,37 @@ const CrewListTable: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.tableHeader]}>
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
|
||||||
Mã định danh
|
|
||||||
</Text>
|
|
||||||
<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>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{data.map((item) => (
|
{data.map((item) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={item.Person.personal_id} style={styles.row}>
|
||||||
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.cellWrapper}
|
style={styles.cellWrapper}
|
||||||
onPress={() => handleCrewPress(item.id)}
|
onPress={() => handleCrewPress(item.Person.personal_id)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.cell, styles.linkText]}>{item.ten}</Text>
|
<Text style={[styles.cell, styles.linkText]}>
|
||||||
|
{item.Person.name}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
|
<Text style={[styles.cell, styles.right]}>{item.role}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
<Text style={[styles.cell, styles.footerText]}>
|
||||||
Tổng cộng
|
{t("trip.crewList.totalLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||||
<Text style={[styles.cell, styles.right]}></Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -123,40 +119,46 @@ const CrewListTable: 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.cell, styles.left, styles.headerText]}>
|
|
||||||
Mã định danh
|
|
||||||
</Text>
|
|
||||||
<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>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{data.map((item) => (
|
{data.map((item) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={item.Person.personal_id} style={styles.row}>
|
||||||
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.cellWrapper}
|
style={styles.cellWrapper}
|
||||||
onPress={() => handleCrewPress(item.id)}
|
onPress={() => handleCrewPress(item.Person.personal_id)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.cell, styles.linkText]}>{item.ten}</Text>
|
<Text style={[styles.cell, styles.linkText]}>
|
||||||
|
{item.Person.name}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
|
<Text style={[styles.cell, styles.right]}>{item.role}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
<Text style={[styles.cell, styles.footerText]}>
|
||||||
Tổng cộng
|
{t("trip.crewList.totalLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||||
<Text style={[styles.cell, styles.right]}></Text>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Modal chi tiết thuyền viên */}
|
||||||
|
<CrewDetailModal
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
crewData={selectedCrew}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import React, { useRef, useState } from "react";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
import styles from "./style/FishingToolsTable.styles";
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
// ---------------------------
|
import { createTableStyles } from "./ThemedTable";
|
||||||
// 🧩 Interface
|
|
||||||
// ---------------------------
|
|
||||||
interface ToolItem {
|
|
||||||
id: string;
|
|
||||||
ten: string;
|
|
||||||
soLuong: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// 🎣 Dữ liệu mẫu
|
|
||||||
// ---------------------------
|
|
||||||
const data: ToolItem[] = [
|
|
||||||
{ id: "1", ten: "Lưới kéo", soLuong: 1 },
|
|
||||||
{ id: "2", ten: "Cần câu", soLuong: 1 },
|
|
||||||
{ id: "3", ten: "Mồi câu", soLuong: 5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FishingToolsTable: React.FC = () => {
|
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 tongSoLuong = data.reduce((sum, item) => sum + item.soLuong, 0);
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
const data: Model.FishingGear[] = trip?.fishing_gears ?? [];
|
||||||
|
const tongSoLuong = data.reduce((sum, item) => sum + Number(item.number), 0);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
const toValue = collapsed ? contentHeight : 0;
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
@@ -45,12 +38,12 @@ 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 ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={16}
|
size={16}
|
||||||
color="#000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -66,24 +59,26 @@ 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>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{data.map((item) => (
|
{data.map((item, index) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={index} style={styles.row}>
|
||||||
<Text style={[styles.cell, styles.left]}>{item.ten}</Text>
|
<Text style={[styles.cell, styles.left]}>{item.name}</Text>
|
||||||
<Text style={[styles.cell, styles.right]}>{item.soLuong}</Text>
|
<Text style={[styles.cell, styles.right]}>{item.number}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 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}
|
||||||
@@ -95,24 +90,26 @@ 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>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{data.map((item) => (
|
{data.map((item, index) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={index} style={styles.row}>
|
||||||
<Text style={[styles.cell, styles.left]}>{item.ten}</Text>
|
<Text style={[styles.cell, styles.left]}>{item.name}</Text>
|
||||||
<Text style={[styles.cell, styles.right]}>{item.soLuong}</Text>
|
<Text style={[styles.cell, styles.right]}>{item.number}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 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,31 +1,30 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import React, { useRef, useState } from "react";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useFishes } from "@/state/use-fish";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
import styles from "./style/NetListTable.styles";
|
import CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
// ---------------------------
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
// 🧩 Interface
|
import { createTableStyles } from "./ThemedTable";
|
||||||
// ---------------------------
|
|
||||||
interface NetItem {
|
|
||||||
id: string;
|
|
||||||
stt: string;
|
|
||||||
trangThai: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// 🧵 Dữ liệu mẫu
|
|
||||||
// ---------------------------
|
|
||||||
const data: NetItem[] = [
|
|
||||||
{ id: "1", stt: "Mẻ 3", trangThai: "Đã hoàn thành" },
|
|
||||||
{ id: "2", stt: "Mẻ 2", trangThai: "Đã hoàn thành" },
|
|
||||||
{ id: "3", stt: "Mẻ 1", trangThai: "Đã hoàn thành" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const NetListTable: React.FC = () => {
|
const NetListTable: 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 tongSoMe = data.length;
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedNet, setSelectedNet] = useState<Model.FishingLog | null>(null);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
const { fishSpecies, getFishSpecies } = useFishes();
|
||||||
|
useEffect(() => {
|
||||||
|
getFishSpecies();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
const toValue = collapsed ? contentHeight : 0;
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
@@ -38,7 +37,11 @@ const NetListTable: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusPress = (id: string) => {
|
const handleStatusPress = (id: string) => {
|
||||||
console.log(`ID mẻ lưới: ${id}`);
|
const net = trip?.fishing_logs?.find((item) => item.fishing_log_id === id);
|
||||||
|
if (net) {
|
||||||
|
setSelectedNet(net);
|
||||||
|
setModalVisible(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,12 +52,16 @@ 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 && <Text style={styles.totalCollapsed}>{tongSoMe}</Text>}
|
{collapsed && (
|
||||||
|
<Text style={styles.totalCollapsed}>
|
||||||
|
{trip?.fishing_logs?.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={16}
|
size={16}
|
||||||
color="#000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -63,28 +70,55 @@ const NetListTable: React.FC = () => {
|
|||||||
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
||||||
onLayout={(event) => {
|
onLayout={(event) => {
|
||||||
const height = event.nativeEvent.layout.height;
|
const height = event.nativeEvent.layout.height;
|
||||||
if (height > 0 && contentHeight === 0) {
|
// Update measured content height whenever it actually changes.
|
||||||
|
if (height > 0 && height !== contentHeight) {
|
||||||
setContentHeight(height);
|
setContentHeight(height);
|
||||||
|
// If the panel is currently expanded, animate to the new height so
|
||||||
|
// newly added/removed rows become visible immediately.
|
||||||
|
if (!collapsed) {
|
||||||
|
Animated.timing(animatedHeight, {
|
||||||
|
toValue: height,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 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 */}
|
||||||
{data.map((item) => (
|
{trip?.fishing_logs?.map((item, index) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={item.fishing_log_id} style={styles.row}>
|
||||||
{/* Cột STT */}
|
{/* Cột STT */}
|
||||||
<Text style={styles.sttCell}>{item.stt}</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]}>
|
||||||
<View style={styles.statusDot} />
|
<View
|
||||||
<TouchableOpacity onPress={() => handleStatusPress(item.id)}>
|
style={[
|
||||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
styles.statusDot,
|
||||||
|
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleStatusPress(item.fishing_log_id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{item.status
|
||||||
|
? t("trip.netList.completed")
|
||||||
|
: t("trip.netList.pending")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -95,26 +129,67 @@ 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 */}
|
||||||
{data.map((item) => (
|
{trip?.fishing_logs?.map((item, index) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={item.fishing_log_id} style={styles.row}>
|
||||||
{/* Cột STT */}
|
{/* Cột STT */}
|
||||||
<Text style={styles.sttCell}>{item.stt}</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]}>
|
||||||
<View style={styles.statusDot} />
|
<View
|
||||||
<TouchableOpacity onPress={() => handleStatusPress(item.id)}>
|
style={[
|
||||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
styles.statusDot,
|
||||||
|
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleStatusPress(item.fishing_log_id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{item.status
|
||||||
|
? t("trip.netList.completed")
|
||||||
|
: t("trip.netList.pending")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
<CreateOrUpdateHaulModal
|
||||||
|
isVisible={modalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
console.log("OnCLose");
|
||||||
|
setModalVisible(false);
|
||||||
|
}}
|
||||||
|
fishingLog={selectedNet}
|
||||||
|
fishingLogIndex={
|
||||||
|
selectedNet
|
||||||
|
? trip!.fishing_logs!.findIndex(
|
||||||
|
(item) => item.fishing_log_id === selectedNet.fishing_log_id
|
||||||
|
) + 1
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Modal chi tiết */}
|
||||||
|
{/* <NetDetailModal
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
console.log("OnCLose");
|
||||||
|
setModalVisible(false);
|
||||||
|
}}
|
||||||
|
netData={selectedNet}
|
||||||
|
/> */}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
29
components/tripInfo/ThemedTable.tsx
Normal file
29
components/tripInfo/ThemedTable.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Wrapper component to easily apply theme-aware table styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { createTableStyles } from "./style/createTableStyles";
|
||||||
|
|
||||||
|
interface ThemedTableProps extends ViewProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedTable({ style, children, ...props }: ThemedTableProps) {
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const tableStyles = useMemo(
|
||||||
|
() => createTableStyles(colorScheme),
|
||||||
|
[colorScheme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[tableStyles.container, style]} {...props}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createTableStyles };
|
||||||
|
export type { TableStyles } from "./style/createTableStyles";
|
||||||
@@ -1,67 +1,28 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import React, { useRef, useState } from "react";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import { createTableStyles } from "./style/createTableStyles";
|
||||||
|
import TripCostDetailModal from "./modal/TripCostDetailModal";
|
||||||
|
import React, { useRef, useState, useMemo } from "react";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
import styles from "./style/TripCostTable.styles";
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// 🧩 Interface
|
|
||||||
// ---------------------------
|
|
||||||
interface CostItem {
|
|
||||||
id: string;
|
|
||||||
loai: string;
|
|
||||||
soLuong: number;
|
|
||||||
donVi: string;
|
|
||||||
chiPhi: number;
|
|
||||||
tongChiPhi: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// 📊 Dữ liệu mẫu
|
|
||||||
// ---------------------------
|
|
||||||
const data: CostItem[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
loai: "Nhiên liệu",
|
|
||||||
soLuong: 1000,
|
|
||||||
donVi: "liters",
|
|
||||||
chiPhi: 20000,
|
|
||||||
tongChiPhi: 20000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
loai: "Lương thực",
|
|
||||||
soLuong: 500,
|
|
||||||
donVi: "kg",
|
|
||||||
chiPhi: 30000,
|
|
||||||
tongChiPhi: 15000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
loai: "Lương thuyền viên",
|
|
||||||
soLuong: 10,
|
|
||||||
donVi: "people",
|
|
||||||
chiPhi: 5000000,
|
|
||||||
tongChiPhi: 50000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
loai: "Muối đá",
|
|
||||||
soLuong: 100,
|
|
||||||
donVi: "kg",
|
|
||||||
chiPhi: 20000,
|
|
||||||
tongChiPhi: 2000000,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// 💰 Component chính
|
|
||||||
// ---------------------------
|
|
||||||
|
|
||||||
const TripCostTable: React.FC = () => {
|
const TripCostTable: 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 [modalVisible, setModalVisible] = useState(false);
|
||||||
const animatedHeight = useRef(new Animated.Value(0)).current;
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
const tongCong = data.reduce((sum, item) => sum + item.tongChiPhi, 0);
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const data: Model.TripCost[] = trip?.trip_cost ?? [];
|
||||||
|
const tongCong = data.reduce((sum, item) => sum + item.total_cost, 0);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
const toValue = collapsed ? contentHeight : 0;
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
@@ -74,7 +35,11 @@ const TripCostTable: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleViewDetail = () => {
|
const handleViewDetail = () => {
|
||||||
console.log("View trip cost details");
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,21 +54,16 @@ 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={[styles.totalCollapsed]}>
|
||||||
style={[
|
|
||||||
styles.title,
|
|
||||||
{ color: "#ff6600", fontWeight: "bold", marginLeft: 8 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{tongCong.toLocaleString()}
|
{tongCong.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={15}
|
size={15}
|
||||||
color="#000000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -120,17 +80,19 @@ 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 */}
|
||||||
{data.map((item) => (
|
{data.map((item, index) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={index} style={styles.row}>
|
||||||
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
|
<Text style={[styles.cell, styles.left]}>{item.type}</Text>
|
||||||
<Text style={[styles.cell, styles.right]}>
|
<Text style={[styles.cell, styles.right]}>
|
||||||
{item.tongChiPhi.toLocaleString()}
|
{item.total_cost.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -138,7 +100,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()}
|
||||||
@@ -146,29 +108,35 @@ const TripCostTable: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* View Detail Button */}
|
{/* View Detail Button */}
|
||||||
<TouchableOpacity
|
{data.length > 0 && (
|
||||||
style={styles.viewDetailButton}
|
<TouchableOpacity
|
||||||
onPress={handleViewDetail}
|
style={styles.viewDetailButton}
|
||||||
>
|
onPress={handleViewDetail}
|
||||||
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
|
>
|
||||||
</TouchableOpacity>
|
<Text style={styles.viewDetailText}>
|
||||||
|
{t("trip.costTable.viewDetail")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||||
{/* 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 */}
|
||||||
{data.map((item) => (
|
{data.map((item, index) => (
|
||||||
<View key={item.id} style={styles.row}>
|
<View key={index} style={styles.row}>
|
||||||
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
|
<Text style={[styles.cell, styles.left]}>{item.type}</Text>
|
||||||
<Text style={[styles.cell, styles.right]}>
|
<Text style={[styles.cell, styles.right]}>
|
||||||
{item.tongChiPhi.toLocaleString()}
|
{item.total_cost.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -176,7 +144,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()}
|
||||||
@@ -184,13 +152,24 @@ const TripCostTable: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* View Detail Button */}
|
{/* View Detail Button */}
|
||||||
<TouchableOpacity
|
{data.length > 0 && (
|
||||||
style={styles.viewDetailButton}
|
<TouchableOpacity
|
||||||
onPress={handleViewDetail}
|
style={styles.viewDetailButton}
|
||||||
>
|
onPress={handleViewDetail}
|
||||||
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
|
>
|
||||||
</TouchableOpacity>
|
<Text style={styles.viewDetailText}>
|
||||||
|
{t("trip.costTable.viewDetail")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<TripCostDetailModal
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
584
components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
Normal file
584
components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
import Select from "@/components/Select";
|
||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { queryGpsData } from "@/controller/DeviceController";
|
||||||
|
import { queryUpdateFishingLogs } from "@/controller/TripController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { showErrorToast, showSuccessToast } from "@/services/toast_service";
|
||||||
|
import { useFishes } from "@/state/use-fish";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import React from "react";
|
||||||
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { InfoSection } from "./NetDetailModal/components";
|
||||||
|
import styles from "./style/CreateOrUpdateHaulModal.styles";
|
||||||
|
|
||||||
|
interface CreateOrUpdateHaulModalProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
fishingLog?: Model.FishingLog | null;
|
||||||
|
fishingLogIndex?: number;
|
||||||
|
}
|
||||||
|
const UNITS = ["con", "kg", "tấn"] as const;
|
||||||
|
type Unit = (typeof UNITS)[number];
|
||||||
|
|
||||||
|
const UNITS_OPTIONS = UNITS.map((unit) => ({
|
||||||
|
label: unit,
|
||||||
|
value: unit.toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SIZE_UNITS = ["cm", "m"] as const;
|
||||||
|
type SizeUnit = (typeof SIZE_UNITS)[number];
|
||||||
|
|
||||||
|
const SIZE_UNITS_OPTIONS = SIZE_UNITS.map((unit) => ({
|
||||||
|
label: unit,
|
||||||
|
value: unit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Zod schema cho 1 dòng cá
|
||||||
|
const fishItemSchema = z.object({
|
||||||
|
id: z.number().min(1, ""),
|
||||||
|
quantity: z.number({ invalid_type_error: "" }).positive(""),
|
||||||
|
unit: z.enum(UNITS, { required_error: "" }),
|
||||||
|
size: z.number({ invalid_type_error: "" }).positive("").optional(),
|
||||||
|
sizeUnit: z.enum(SIZE_UNITS),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema tổng: mảng các item
|
||||||
|
const formSchema = z.object({
|
||||||
|
fish: z.array(fishItemSchema).min(1, ""),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const defaultItem = (): FormValues["fish"][number] => ({
|
||||||
|
id: -1,
|
||||||
|
quantity: 1,
|
||||||
|
unit: "con",
|
||||||
|
size: undefined,
|
||||||
|
sizeUnit: "cm",
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
|
||||||
|
isVisible,
|
||||||
|
onClose,
|
||||||
|
fishingLog,
|
||||||
|
fishingLogIndex,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [isCreateMode, setIsCreateMode] = React.useState(!fishingLog?.info);
|
||||||
|
const [isEditing, setIsEditing] = React.useState(false);
|
||||||
|
const [expandedFishIndices, setExpandedFishIndices] = React.useState<
|
||||||
|
number[]
|
||||||
|
>([]);
|
||||||
|
const { trip, getTrip } = useTrip();
|
||||||
|
const { control, handleSubmit, formState, watch, reset } =
|
||||||
|
useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
fish: [defaultItem()],
|
||||||
|
},
|
||||||
|
mode: "onSubmit",
|
||||||
|
});
|
||||||
|
const { fishSpecies, getFishSpecies } = useFishes();
|
||||||
|
const { errors } = formState;
|
||||||
|
if (!fishSpecies) {
|
||||||
|
getFishSpecies();
|
||||||
|
}
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "fish",
|
||||||
|
keyName: "_id", // tránh đụng key
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleExpanded = (index: number) => {
|
||||||
|
setExpandedFishIndices((prev) =>
|
||||||
|
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
// Ensure species list is available so we can populate name/rarity
|
||||||
|
if (!fishSpecies || fishSpecies.length === 0) {
|
||||||
|
showErrorToast(t("trip.createHaulModal.fishListNotReady"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Helper to map form rows -> API info entries (single place)
|
||||||
|
const buildInfo = (rows: FormValues["fish"]) =>
|
||||||
|
rows.map((item) => {
|
||||||
|
const meta = fishSpecies.find((f) => f.id === item.id);
|
||||||
|
return {
|
||||||
|
fish_species_id: item.id,
|
||||||
|
fish_name: meta?.name ?? "",
|
||||||
|
catch_number: item.quantity,
|
||||||
|
catch_unit: item.unit,
|
||||||
|
fish_size: item.size,
|
||||||
|
fish_rarity: meta?.rarity_level ?? null,
|
||||||
|
fish_condition: "",
|
||||||
|
gear_usage: "",
|
||||||
|
} as unknown;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gpsResp = await queryGpsData();
|
||||||
|
if (!gpsResp.data) {
|
||||||
|
showErrorToast(t("trip.createHaulModal.gpsError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gpsData = gpsResp.data;
|
||||||
|
|
||||||
|
const info = buildInfo(values.fish) as any;
|
||||||
|
|
||||||
|
// Base payload fields shared between create and update
|
||||||
|
const base: Partial<Model.FishingLog> = {
|
||||||
|
fishing_log_id: fishingLog?.fishing_log_id || "",
|
||||||
|
trip_id: trip?.id || "",
|
||||||
|
start_at: fishingLog?.start_at!,
|
||||||
|
start_lat: fishingLog?.start_lat!,
|
||||||
|
start_lon: fishingLog?.start_lon!,
|
||||||
|
weather_description:
|
||||||
|
fishingLog?.weather_description || "Nắng đẹp, Trời nhiều mây",
|
||||||
|
info,
|
||||||
|
sync: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build final payload depending on create vs update
|
||||||
|
const body: Model.FishingLog =
|
||||||
|
fishingLog?.status == 0
|
||||||
|
? ({
|
||||||
|
...base,
|
||||||
|
haul_lat: gpsData.lat,
|
||||||
|
haul_lon: gpsData.lon,
|
||||||
|
end_at: new Date(),
|
||||||
|
status: 1,
|
||||||
|
} as Model.FishingLog)
|
||||||
|
: ({
|
||||||
|
...base,
|
||||||
|
haul_lat: fishingLog?.haul_lat,
|
||||||
|
haul_lon: fishingLog?.haul_lon,
|
||||||
|
end_at: fishingLog?.end_at,
|
||||||
|
status: fishingLog?.status,
|
||||||
|
} as Model.FishingLog);
|
||||||
|
// console.log("Body: ", body);
|
||||||
|
|
||||||
|
const resp = await queryUpdateFishingLogs(body);
|
||||||
|
if (resp?.status === 200) {
|
||||||
|
showSuccessToast(
|
||||||
|
fishingLog?.fishing_log_id == null
|
||||||
|
? t("trip.createHaulModal.addSuccess")
|
||||||
|
: t("trip.createHaulModal.updateSuccess")
|
||||||
|
);
|
||||||
|
getTrip();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showErrorToast(
|
||||||
|
fishingLog?.fishing_log_id == null
|
||||||
|
? t("trip.createHaulModal.addError")
|
||||||
|
: t("trip.createHaulModal.updateError")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("onSubmit error:", err);
|
||||||
|
showErrorToast(t("trip.createHaulModal.validationError"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize / reset form when modal visibility or haulData changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isVisible) {
|
||||||
|
// when modal closed, clear form to default
|
||||||
|
reset({ fish: [defaultItem()] });
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setIsEditing(false);
|
||||||
|
setExpandedFishIndices([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// when modal opened, populate based on fishingLog
|
||||||
|
if (fishingLog?.info === null) {
|
||||||
|
// explicit null -> start with a single default item
|
||||||
|
reset({ fish: [defaultItem()] });
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setIsEditing(true); // allow editing for new haul
|
||||||
|
setExpandedFishIndices([0]); // expand first item
|
||||||
|
} else if (Array.isArray(fishingLog?.info) && fishingLog?.info.length > 0) {
|
||||||
|
// map FishingLogInfo -> form rows
|
||||||
|
const mapped = fishingLog.info.map((h) => ({
|
||||||
|
id: h.fish_species_id ?? -1,
|
||||||
|
quantity: (h.catch_number as number) ?? 1,
|
||||||
|
unit: (h.catch_unit as Unit) ?? (defaultItem().unit as Unit),
|
||||||
|
size: (h.fish_size as number) ?? undefined,
|
||||||
|
sizeUnit: "cm" as SizeUnit,
|
||||||
|
}));
|
||||||
|
reset({ fish: mapped as any });
|
||||||
|
setIsCreateMode(false);
|
||||||
|
setIsEditing(false); // view mode by default
|
||||||
|
setExpandedFishIndices([]); // all collapsed
|
||||||
|
} else {
|
||||||
|
// undefined or empty array -> default
|
||||||
|
reset({ fish: [defaultItem()] });
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setIsEditing(true); // allow editing for new haul
|
||||||
|
setExpandedFishIndices([0]); // expand first item
|
||||||
|
}
|
||||||
|
}, [isVisible, fishingLog?.info, reset]);
|
||||||
|
const renderRow = (item: any, index: number) => {
|
||||||
|
const isExpanded = expandedFishIndices.includes(index);
|
||||||
|
// Give expanded card highest zIndex, others get decreasing zIndex based on position
|
||||||
|
const cardZIndex = isExpanded ? 1000 : 100 - index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={item._id} style={[styles.fishCard, { zIndex: cardZIndex }]}>
|
||||||
|
{/* Delete + Chevron buttons - top right corner */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 8,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
{isEditing && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => remove(index)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#FF3B30",
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<IconSymbol name="trash" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleToggleExpanded(index)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<IconSymbol
|
||||||
|
name={isExpanded ? "chevron.up" : "chevron.down"}
|
||||||
|
size={24}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Header - visible when collapsed */}
|
||||||
|
{!isExpanded && (
|
||||||
|
<View style={{ paddingRight: 100 }}>
|
||||||
|
{(() => {
|
||||||
|
const fishId = watch(`fish.${index}.id`);
|
||||||
|
const fishName = fishSpecies?.find((f) => f.id === fishId)?.name;
|
||||||
|
const quantity = watch(`fish.${index}.quantity`);
|
||||||
|
const unit = watch(`fish.${index}.unit`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.fishCardHeaderContent}>
|
||||||
|
<Text style={styles.fishCardTitle}>
|
||||||
|
{fishName || t("trip.createHaulModal.selectFish")}:
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fishCardSubtitle}>
|
||||||
|
{fishName ? `${quantity} ${unit}` : "---"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form - visible when expanded */}
|
||||||
|
{isExpanded && (
|
||||||
|
<View style={{ paddingRight: 10 }}>
|
||||||
|
{/* Species dropdown */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.id`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<View style={[styles.fieldGroup, { marginTop: 20 }]}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.fishName")}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={fishSpecies!.map((fish) => ({
|
||||||
|
label: fish.name,
|
||||||
|
value: fish.id,
|
||||||
|
}))}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={t("trip.createHaulModal.selectFish")}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.id && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.selectFish")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Số lượng & Đơn vị cùng hàng */}
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.quantity`}
|
||||||
|
render={({ field: { value, onChange, onBlur } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.quantity")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChangeText={(t) =>
|
||||||
|
onChange(Number(t.replace(/,/g, ".")) || 0)
|
||||||
|
}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
!isEditing && styles.inputDisabled,
|
||||||
|
]}
|
||||||
|
editable={isEditing}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.quantity && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.quantity")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.unit`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.unit")}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={UNITS_OPTIONS.map((unit) => ({
|
||||||
|
label: unit.label,
|
||||||
|
value: unit.value,
|
||||||
|
}))}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={t("trip.createHaulModal.unit")}
|
||||||
|
disabled={!isEditing}
|
||||||
|
listStyle={{ maxHeight: 100 }}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.unit && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.unit")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Size (optional) + Unit dropdown */}
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.size`}
|
||||||
|
render={({ field: { value, onChange, onBlur } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.size")} (
|
||||||
|
{t("trip.createHaulModal.optional")})
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={value ? String(value) : ""}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChangeText={(t) =>
|
||||||
|
onChange(t ? Number(t.replace(/,/g, ".")) : undefined)
|
||||||
|
}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
!isEditing && styles.inputDisabled,
|
||||||
|
]}
|
||||||
|
editable={isEditing}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.size && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.size")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.sizeUnit`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.unit")}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={SIZE_UNITS_OPTIONS}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={t("trip.createHaulModal.unit")}
|
||||||
|
disabled={!isEditing}
|
||||||
|
listStyle={{ maxHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={isVisible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
keyboardVerticalOffset={60}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{isCreateMode
|
||||||
|
? t("trip.createHaulModal.addFish")
|
||||||
|
: t("trip.createHaulModal.edit")}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.headerButtons}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
{!isCreateMode && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
reset(); // reset to previous values
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.saveButton,
|
||||||
|
{ backgroundColor: "#6c757d" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.createHaulModal.cancel")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSubmit(onSubmit)}
|
||||||
|
style={styles.saveButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.createHaulModal.save")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!isCreateMode && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setIsEditing(true)}
|
||||||
|
style={[styles.saveButton, { backgroundColor: "#17a2b8" }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.createHaulModal.edit")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<View style={styles.closeIconButton}>
|
||||||
|
<IconSymbol name="xmark" size={24} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{/* Info Section */}
|
||||||
|
<InfoSection fishingLog={fishingLog!} stt={fishingLogIndex} />
|
||||||
|
|
||||||
|
{/* Fish List */}
|
||||||
|
{fields.map((item, index) => renderRow(item, index))}
|
||||||
|
|
||||||
|
{/* Add Button - only show when editing */}
|
||||||
|
{isEditing && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => append(defaultItem())}
|
||||||
|
style={styles.addButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.addButtonText}>
|
||||||
|
+ {t("trip.createHaulModal.addFish")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{errors.fish && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.validationError")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateOrUpdateHaulModal;
|
||||||
102
components/tripInfo/modal/CrewDetailModal.tsx
Normal file
102
components/tripInfo/modal/CrewDetailModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import React from "react";
|
||||||
|
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import styles from "./style/CrewDetailModal.styles";
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 🧩 Interface
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
interface CrewDetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
crewData: Model.TripCrews | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 👤 Component Modal
|
||||||
|
// ---------------------------
|
||||||
|
const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
crewData,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (!crewData) return null;
|
||||||
|
|
||||||
|
const infoItems = [
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.personalId"),
|
||||||
|
value: crewData.Person.personal_id,
|
||||||
|
},
|
||||||
|
{ label: t("trip.crewDetailModal.fullName"), value: crewData.Person.name },
|
||||||
|
{ label: t("trip.crewDetailModal.role"), value: crewData.role },
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.birthDate"),
|
||||||
|
value: crewData.Person.birth_date
|
||||||
|
? new Date(crewData.Person.birth_date).toLocaleDateString()
|
||||||
|
: t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.phone"),
|
||||||
|
value: crewData.Person.phone || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.address"),
|
||||||
|
value: crewData.Person.address || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.joinedDate"),
|
||||||
|
value: crewData.joined_at
|
||||||
|
? new Date(crewData.joined_at).toLocaleDateString()
|
||||||
|
: t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.note"),
|
||||||
|
value: crewData.note || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.status"),
|
||||||
|
value: crewData.left_at
|
||||||
|
? t("trip.crewDetailModal.resigned")
|
||||||
|
: t("trip.crewDetailModal.working"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("trip.crewDetailModal.title")}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<View style={styles.closeIconButton}>
|
||||||
|
<IconSymbol name="xmark" size={28} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
{infoItems.map((item, index) => (
|
||||||
|
<View key={index} style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{item.label}</Text>
|
||||||
|
<Text style={styles.infoValue}>{item.value}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CrewDetailModal;
|
||||||
362
components/tripInfo/modal/NetDetailModal/NetDetailModal.tsx
Normal file
362
components/tripInfo/modal/NetDetailModal/NetDetailModal.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import styles from "../style/NetDetailModal.styles";
|
||||||
|
import { CatchSectionHeader } from "./components/CatchSectionHeader";
|
||||||
|
import { FishCardList } from "./components/FishCardList";
|
||||||
|
import { NotesSection } from "./components/NotesSection";
|
||||||
|
|
||||||
|
interface NetDetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
netData: Model.FishingLog | null;
|
||||||
|
stt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 🧵 Component Modal
|
||||||
|
// ---------------------------
|
||||||
|
const NetDetailModal: React.FC<NetDetailModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
netData,
|
||||||
|
stt,
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editableCatchList, setEditableCatchList] = useState<
|
||||||
|
Model.FishingLogInfo[]
|
||||||
|
>([]);
|
||||||
|
const [selectedFishIndex, setSelectedFishIndex] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [selectedUnitIndex, setSelectedUnitIndex] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
// const [selectedConditionIndex, setSelectedConditionIndex] = useState<
|
||||||
|
// number | null
|
||||||
|
// >(null);
|
||||||
|
// const [selectedGearIndex, setSelectedGearIndex] = useState<number | null>(
|
||||||
|
// null
|
||||||
|
// );
|
||||||
|
const [expandedFishIndices, setExpandedFishIndices] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Khởi tạo dữ liệu khi netData thay đổi
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (netData?.info) {
|
||||||
|
setEditableCatchList(netData.info);
|
||||||
|
}
|
||||||
|
}, [netData]);
|
||||||
|
|
||||||
|
// Reset state khi modal đóng
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setExpandedFishIndices([]);
|
||||||
|
setSelectedFishIndex(null);
|
||||||
|
setSelectedUnitIndex(null);
|
||||||
|
// setSelectedConditionIndex(null);
|
||||||
|
// setSelectedGearIndex(null);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// if (!netData) return null;
|
||||||
|
|
||||||
|
const isCompleted = netData?.status === 2; // ví dụ: status=2 là hoàn thành
|
||||||
|
|
||||||
|
// Danh sách tên cá có sẵn
|
||||||
|
const fishNameOptions = [
|
||||||
|
"Cá chim trắng",
|
||||||
|
"Cá song đỏ",
|
||||||
|
"Cá hồng",
|
||||||
|
"Cá nục",
|
||||||
|
"Cá ngừ đại dương",
|
||||||
|
"Cá mú trắng",
|
||||||
|
"Cá hồng phớn",
|
||||||
|
"Cá hổ Napoleon",
|
||||||
|
"Cá nược",
|
||||||
|
"Cá đuối quạt",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Danh sách đơn vị
|
||||||
|
const unitOptions = ["kg", "con", "tấn"];
|
||||||
|
|
||||||
|
// Danh sách tình trạng
|
||||||
|
// const conditionOptions = ["Còn sống", "Chết", "Bị thương"];
|
||||||
|
|
||||||
|
// Danh sách ngư cụ
|
||||||
|
// const gearOptions = [
|
||||||
|
// "Lưới kéo",
|
||||||
|
// "Lưới vây",
|
||||||
|
// "Lưới rê",
|
||||||
|
// "Lưới cào",
|
||||||
|
// "Lưới lồng",
|
||||||
|
// "Câu cần",
|
||||||
|
// "Câu dây",
|
||||||
|
// "Chài cá",
|
||||||
|
// "Lồng bẫy",
|
||||||
|
// "Đăng",
|
||||||
|
// ];
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Validate từng cá trong danh sách và thu thập tất cả lỗi
|
||||||
|
const allErrors: { index: number; errors: string[] }[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < editableCatchList.length; i++) {
|
||||||
|
const fish = editableCatchList[i];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!fish.fish_name || fish.fish_name.trim() === "") {
|
||||||
|
errors.push("- Tên loài cá");
|
||||||
|
}
|
||||||
|
if (!fish.catch_number || fish.catch_number <= 0) {
|
||||||
|
errors.push("- Số lượng bắt được");
|
||||||
|
}
|
||||||
|
if (!fish.catch_unit || fish.catch_unit.trim() === "") {
|
||||||
|
errors.push("- Đơn vị");
|
||||||
|
}
|
||||||
|
if (!fish.fish_size || fish.fish_size <= 0) {
|
||||||
|
errors.push("- Kích thước cá");
|
||||||
|
}
|
||||||
|
// if (!fish.fish_condition || fish.fish_condition.trim() === "") {
|
||||||
|
// errors.push("- Tình trạng cá");
|
||||||
|
// }
|
||||||
|
// if (!fish.gear_usage || fish.gear_usage.trim() === "") {
|
||||||
|
// errors.push("- Dụng cụ sử dụng");
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
allErrors.push({ index: i, errors });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu có lỗi, hiển thị tất cả
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
const errorMessage = allErrors
|
||||||
|
.map((item) => {
|
||||||
|
return `Cá số ${item.index + 1}:\n${item.errors.join("\n")}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Thông tin không đầy đủ",
|
||||||
|
errorMessage,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Tiếp tục chỉnh sửa",
|
||||||
|
onPress: () => {
|
||||||
|
// Mở rộng tất cả các card bị lỗi
|
||||||
|
setExpandedFishIndices((prev) => {
|
||||||
|
const errorIndices = allErrors.map((item) => item.index);
|
||||||
|
const newIndices = [...prev];
|
||||||
|
errorIndices.forEach((idx) => {
|
||||||
|
if (!newIndices.includes(idx)) {
|
||||||
|
newIndices.push(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newIndices;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Hủy",
|
||||||
|
onPress: () => {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ cancelable: false }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu validation pass, lưu dữ liệu
|
||||||
|
setIsEditing(false);
|
||||||
|
console.log("Saved catch list:", editableCatchList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditableCatchList(netData?.info || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpanded = (index: number) => {
|
||||||
|
setExpandedFishIndices((prev) =>
|
||||||
|
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCatchItem = (
|
||||||
|
index: number,
|
||||||
|
field: keyof Model.FishingLogInfo,
|
||||||
|
value: string | number
|
||||||
|
) => {
|
||||||
|
setEditableCatchList((prev) =>
|
||||||
|
prev.map((item, i) => {
|
||||||
|
if (i === index) {
|
||||||
|
const updatedItem = { ...item };
|
||||||
|
if (
|
||||||
|
field === "catch_number" ||
|
||||||
|
field === "fish_size" ||
|
||||||
|
field === "fish_rarity"
|
||||||
|
) {
|
||||||
|
updatedItem[field] = Number(value) || 0;
|
||||||
|
} else {
|
||||||
|
updatedItem[field] = value as never;
|
||||||
|
}
|
||||||
|
return updatedItem;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNewFish = () => {
|
||||||
|
const newFish: Model.FishingLogInfo = {
|
||||||
|
fish_species_id: 0,
|
||||||
|
fish_name: "",
|
||||||
|
catch_number: 0,
|
||||||
|
catch_unit: "kg",
|
||||||
|
fish_size: 0,
|
||||||
|
fish_rarity: 0,
|
||||||
|
fish_condition: "",
|
||||||
|
gear_usage: "",
|
||||||
|
};
|
||||||
|
setEditableCatchList((prev) => [...prev, newFish]);
|
||||||
|
// Tự động expand card mới
|
||||||
|
setExpandedFishIndices((prev) => [...prev, editableCatchList.length]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFish = (index: number) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Xác nhận xóa",
|
||||||
|
`Bạn có chắc muốn xóa loài cá này?`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Hủy",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Xóa",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
setEditableCatchList((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
// Cập nhật lại expandedFishIndices sau khi xóa
|
||||||
|
setExpandedFishIndices((prev) =>
|
||||||
|
prev
|
||||||
|
.filter((i) => i !== index)
|
||||||
|
.map((i) => (i > index ? i - 1 : i))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ cancelable: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chỉ tính tổng số lượng cá có đơn vị là 'kg'
|
||||||
|
const totalCatch = editableCatchList.reduce(
|
||||||
|
(sum, item) =>
|
||||||
|
item.catch_unit === "kg" ? sum + (item.catch_number ?? 0) : sum,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Chi tiết mẻ lưới</Text>
|
||||||
|
<View style={styles.headerButtons}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCancel}
|
||||||
|
style={styles.cancelButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Hủy</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
style={styles.saveButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>Lưu</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity onPress={handleEdit} style={styles.editButton}>
|
||||||
|
<View style={styles.editIconButton}>
|
||||||
|
<IconSymbol
|
||||||
|
name="pencil"
|
||||||
|
size={28}
|
||||||
|
color="#fff"
|
||||||
|
weight="heavy"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<View style={styles.closeIconButton}>
|
||||||
|
<IconSymbol name="xmark" size={28} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{/* Thông tin chung */}
|
||||||
|
{/* <InfoSection
|
||||||
|
netData={netData ?? undefined}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
stt={stt}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* Danh sách cá bắt được */}
|
||||||
|
<CatchSectionHeader totalCatch={totalCatch} />
|
||||||
|
|
||||||
|
{/* Fish cards */}
|
||||||
|
<FishCardList
|
||||||
|
catchList={editableCatchList}
|
||||||
|
isEditing={isEditing}
|
||||||
|
expandedFishIndex={expandedFishIndices}
|
||||||
|
selectedFishIndex={selectedFishIndex}
|
||||||
|
selectedUnitIndex={selectedUnitIndex}
|
||||||
|
// selectedConditionIndex={selectedConditionIndex}
|
||||||
|
// selectedGearIndex={selectedGearIndex}
|
||||||
|
fishNameOptions={fishNameOptions}
|
||||||
|
unitOptions={unitOptions}
|
||||||
|
// conditionOptions={conditionOptions}
|
||||||
|
// gearOptions={gearOptions}
|
||||||
|
onToggleExpanded={handleToggleExpanded}
|
||||||
|
onUpdateCatchItem={updateCatchItem}
|
||||||
|
setSelectedFishIndex={setSelectedFishIndex}
|
||||||
|
setSelectedUnitIndex={setSelectedUnitIndex}
|
||||||
|
// setSelectedConditionIndex={setSelectedConditionIndex}
|
||||||
|
// setSelectedGearIndex={setSelectedGearIndex}
|
||||||
|
onAddNewFish={handleAddNewFish}
|
||||||
|
onDeleteFish={handleDeleteFish}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ghi chú */}
|
||||||
|
<NotesSection ghiChu={netData?.weather_description} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NetDetailModal;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import styles from "../../style/NetDetailModal.styles";
|
||||||
|
|
||||||
|
interface CatchSectionHeaderProps {
|
||||||
|
totalCatch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CatchSectionHeader: React.FC<CatchSectionHeaderProps> = ({
|
||||||
|
totalCatch,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Danh sách cá bắt được</Text>
|
||||||
|
<Text style={styles.totalCatchText}>
|
||||||
|
Tổng: {totalCatch.toLocaleString()} kg
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useFishes } from "@/state/use-fish";
|
||||||
|
import React from "react";
|
||||||
|
import { Text, TextInput, View } from "react-native";
|
||||||
|
import styles from "../../style/NetDetailModal.styles";
|
||||||
|
import { FishSelectDropdown } from "./FishSelectDropdown";
|
||||||
|
|
||||||
|
interface FishCardFormProps {
|
||||||
|
fish: Model.FishingLogInfo;
|
||||||
|
index: number;
|
||||||
|
isEditing: boolean;
|
||||||
|
fishNameOptions: string[]; // Bỏ gọi API cá
|
||||||
|
unitOptions: string[]; // Bỏ render ở trong này
|
||||||
|
// conditionOptions: string[];
|
||||||
|
// gearOptions: string[];
|
||||||
|
selectedFishIndex: number | null;
|
||||||
|
selectedUnitIndex: number | null;
|
||||||
|
// selectedConditionIndex: number | null;
|
||||||
|
// selectedGearIndex: number | null;
|
||||||
|
setSelectedFishIndex: (index: number | null) => void;
|
||||||
|
setSelectedUnitIndex: (index: number | null) => void;
|
||||||
|
// setSelectedConditionIndex: (index: number | null) => void;
|
||||||
|
// setSelectedGearIndex: (index: number | null) => void;
|
||||||
|
onUpdateCatchItem: (
|
||||||
|
index: number,
|
||||||
|
field: keyof Model.FishingLogInfo,
|
||||||
|
value: string | number
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FishCardForm: React.FC<FishCardFormProps> = ({
|
||||||
|
fish,
|
||||||
|
index,
|
||||||
|
isEditing,
|
||||||
|
unitOptions,
|
||||||
|
// conditionOptions,
|
||||||
|
// gearOptions,
|
||||||
|
selectedFishIndex,
|
||||||
|
selectedUnitIndex,
|
||||||
|
// selectedConditionIndex,
|
||||||
|
// selectedGearIndex,
|
||||||
|
setSelectedFishIndex,
|
||||||
|
setSelectedUnitIndex,
|
||||||
|
// setSelectedConditionIndex,
|
||||||
|
// setSelectedGearIndex,
|
||||||
|
onUpdateCatchItem,
|
||||||
|
}) => {
|
||||||
|
const { fishSpecies } = useFishes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Tên cá - Select */}
|
||||||
|
<View
|
||||||
|
style={[styles.fieldGroup, { zIndex: 1000 - index }, { marginTop: 15 }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.label}>Tên cá</Text>
|
||||||
|
{isEditing ? (
|
||||||
|
<FishSelectDropdown
|
||||||
|
options={fishSpecies || []}
|
||||||
|
selectedFishId={selectedFishIndex}
|
||||||
|
isOpen={selectedFishIndex === index}
|
||||||
|
onToggle={() =>
|
||||||
|
setSelectedFishIndex(selectedFishIndex === index ? null : index)
|
||||||
|
}
|
||||||
|
onSelect={(value: Model.FishSpeciesResponse) => {
|
||||||
|
onUpdateCatchItem(index, "fish_name", value.name);
|
||||||
|
setSelectedFishIndex(value.id);
|
||||||
|
console.log("Fish Selected: ", fish);
|
||||||
|
}}
|
||||||
|
zIndex={1000 - index}
|
||||||
|
styleOverride={styles.fishNameDropdown}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{fish.fish_name}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Số lượng & Đơn vị */}
|
||||||
|
<View style={styles.rowGroup}>
|
||||||
|
<View style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}>
|
||||||
|
<Text style={styles.label}>Số lượng</Text>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={String(fish.catch_number)}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
onUpdateCatchItem(index, "catch_number", value)
|
||||||
|
}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{fish.catch_number}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.fieldGroup,
|
||||||
|
{ flex: 1, marginLeft: 8, zIndex: 900 - index },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.label}>Đơn vị</Text>
|
||||||
|
{/* {isEditing ? (
|
||||||
|
<FishSelectDropdown
|
||||||
|
options={unitOptions}
|
||||||
|
selectedValue={fish.catch_unit ?? ""}
|
||||||
|
isOpen={selectedUnitIndex === index}
|
||||||
|
onToggle={() =>
|
||||||
|
setSelectedUnitIndex(selectedUnitIndex === index ? null : index)
|
||||||
|
}
|
||||||
|
onSelect={(value: string) => {
|
||||||
|
onUpdateCatchItem(index, "catch_unit", value);
|
||||||
|
setSelectedUnitIndex(null);
|
||||||
|
}}
|
||||||
|
zIndex={900 - index}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{fish.catch_unit}</Text>
|
||||||
|
)} */}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Kích thước & Độ hiếm */}
|
||||||
|
<View style={styles.rowGroup}>
|
||||||
|
<View style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}>
|
||||||
|
<Text style={styles.label}>Kích thước (cm)</Text>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={String(fish.fish_size)}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
onUpdateCatchItem(index, "fish_size", value)
|
||||||
|
}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{fish.fish_size} cm</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
|
||||||
|
<Text style={styles.label}>Độ hiếm</Text>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={String(fish.fish_rarity)}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
onUpdateCatchItem(index, "fish_rarity", value)
|
||||||
|
}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="1-5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{fish.fish_rarity}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tình trạng */}
|
||||||
|
{/* <View style={[styles.fieldGroup, { zIndex: 800 - index }]}>
|
||||||
|
<Text style={styles.label}>Tình trạng</Text>
|
||||||
|
{isEditing ? (
|
||||||
|
<FishSelectDropdown
|
||||||
|
options={conditionOptions}
|
||||||
|
selectedValue={fish.fish_condition}
|
||||||
|
isOpen={selectedConditionIndex === index}
|
||||||
|
onToggle={() =>
|
||||||
|
setSelectedConditionIndex(
|
||||||
|
selectedConditionIndex === index ? null : index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSelect={(value: string) => {
|
||||||
|
onUpdateCatchItem(index, "fish_condition", value);
|
||||||
|
setSelectedConditionIndex(null);
|
||||||
|
}}
|
||||||
|
zIndex={800 - index}
|
||||||
|
styleOverride={styles.optionsStatusFishList}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{fish.fish_condition}</Text>
|
||||||
|
)}
|
||||||
|
</View> */}
|
||||||
|
|
||||||
|
{/* Ngư cụ sử dụng */}
|
||||||
|
{/* <View style={[styles.fieldGroup, { zIndex: 700 - index }]}>
|
||||||
|
<Text style={styles.label}>Ngư cụ sử dụng</Text>
|
||||||
|
{isEditing ? (
|
||||||
|
<FishSelectDropdown
|
||||||
|
options={gearOptions}
|
||||||
|
selectedValue={fish.gear_usage}
|
||||||
|
isOpen={selectedGearIndex === index}
|
||||||
|
onToggle={() =>
|
||||||
|
setSelectedGearIndex(selectedGearIndex === index ? null : index)
|
||||||
|
}
|
||||||
|
onSelect={(value: string) => {
|
||||||
|
onUpdateCatchItem(index, "gear_usage", value);
|
||||||
|
setSelectedGearIndex(null);
|
||||||
|
}}
|
||||||
|
zIndex={700 - index}
|
||||||
|
styleOverride={styles.optionsStatusFishList}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{fish.gear_usage || "Không có"}</Text>
|
||||||
|
)}
|
||||||
|
</View> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import styles from "../../style/NetDetailModal.styles";
|
||||||
|
|
||||||
|
interface FishCardHeaderProps {
|
||||||
|
fish: Model.FishingLogInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FishCardHeader: React.FC<FishCardHeaderProps> = ({ fish }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.fishCardHeaderContent}>
|
||||||
|
<Text style={styles.fishCardTitle}>{fish.fish_name}:</Text>
|
||||||
|
<Text style={styles.fishCardSubtitle}>
|
||||||
|
{fish.catch_number} {fish.catch_unit}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import React from "react";
|
||||||
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import styles from "../../style/NetDetailModal.styles";
|
||||||
|
import { FishCardForm } from "./FishCardForm";
|
||||||
|
import { FishCardHeader } from "./FishCardHeader";
|
||||||
|
|
||||||
|
interface FishCardListProps {
|
||||||
|
catchList: Model.FishingLogInfo[];
|
||||||
|
isEditing: boolean;
|
||||||
|
expandedFishIndex: number[];
|
||||||
|
selectedFishIndex: number | null;
|
||||||
|
selectedUnitIndex: number | null;
|
||||||
|
// selectedConditionIndex: number | null;
|
||||||
|
// selectedGearIndex: number | null;
|
||||||
|
fishNameOptions: string[];
|
||||||
|
unitOptions: string[];
|
||||||
|
// conditionOptions: string[];
|
||||||
|
// gearOptions: string[];
|
||||||
|
onToggleExpanded: (index: number) => void;
|
||||||
|
onUpdateCatchItem: (
|
||||||
|
index: number,
|
||||||
|
field: keyof Model.FishingLogInfo,
|
||||||
|
value: string | number
|
||||||
|
) => void;
|
||||||
|
setSelectedFishIndex: (index: number | null) => void;
|
||||||
|
setSelectedUnitIndex: (index: number | null) => void;
|
||||||
|
// setSelectedConditionIndex: (index: number | null) => void;
|
||||||
|
// setSelectedGearIndex: (index: number | null) => void;
|
||||||
|
onAddNewFish?: () => void;
|
||||||
|
onDeleteFish?: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FishCardList: React.FC<FishCardListProps> = ({
|
||||||
|
catchList,
|
||||||
|
isEditing,
|
||||||
|
expandedFishIndex,
|
||||||
|
selectedFishIndex,
|
||||||
|
selectedUnitIndex,
|
||||||
|
// selectedConditionIndex,
|
||||||
|
// selectedGearIndex,
|
||||||
|
fishNameOptions,
|
||||||
|
unitOptions,
|
||||||
|
// conditionOptions,
|
||||||
|
// gearOptions,
|
||||||
|
onToggleExpanded,
|
||||||
|
onUpdateCatchItem,
|
||||||
|
setSelectedFishIndex,
|
||||||
|
setSelectedUnitIndex,
|
||||||
|
// setSelectedConditionIndex,
|
||||||
|
// setSelectedGearIndex,
|
||||||
|
onAddNewFish,
|
||||||
|
onDeleteFish,
|
||||||
|
}) => {
|
||||||
|
// Chuyển về logic đơn giản, không animation
|
||||||
|
const handleToggleCard = (index: number) => {
|
||||||
|
onToggleExpanded(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{catchList.map((fish, index) => (
|
||||||
|
<View key={index} style={styles.fishCard}>
|
||||||
|
{/* Delete + Chevron buttons - always on top, right side, horizontal row */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 8,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
{isEditing && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onDeleteFish?.(index)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#FF3B30",
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<IconSymbol name="trash" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleToggleCard(index)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<IconSymbol
|
||||||
|
name={
|
||||||
|
expandedFishIndex.includes(index)
|
||||||
|
? "chevron.up"
|
||||||
|
: "chevron.down"
|
||||||
|
}
|
||||||
|
size={24}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Header - Only visible when collapsed */}
|
||||||
|
{!expandedFishIndex.includes(index) && <FishCardHeader fish={fish} />}
|
||||||
|
|
||||||
|
{/* Form - Only show when expanded */}
|
||||||
|
{expandedFishIndex.includes(index) && (
|
||||||
|
<FishCardForm
|
||||||
|
fish={fish}
|
||||||
|
index={index}
|
||||||
|
isEditing={isEditing}
|
||||||
|
fishNameOptions={fishNameOptions}
|
||||||
|
unitOptions={unitOptions}
|
||||||
|
// conditionOptions={conditionOptions}
|
||||||
|
// gearOptions={gearOptions}
|
||||||
|
selectedFishIndex={selectedFishIndex}
|
||||||
|
selectedUnitIndex={selectedUnitIndex}
|
||||||
|
// selectedConditionIndex={selectedConditionIndex}
|
||||||
|
// selectedGearIndex={selectedGearIndex}
|
||||||
|
setSelectedFishIndex={setSelectedFishIndex}
|
||||||
|
setSelectedUnitIndex={setSelectedUnitIndex}
|
||||||
|
// setSelectedConditionIndex={setSelectedConditionIndex}
|
||||||
|
// setSelectedGearIndex={setSelectedGearIndex}
|
||||||
|
onUpdateCatchItem={onUpdateCatchItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Nút thêm loài cá mới - hiển thị khi đang chỉnh sửa */}
|
||||||
|
{isEditing && (
|
||||||
|
<TouchableOpacity onPress={onAddNewFish} style={styles.addFishButton}>
|
||||||
|
<View style={styles.addFishButtonContent}>
|
||||||
|
<IconSymbol name="plus" size={24} color="#fff" />
|
||||||
|
<Text style={styles.addFishButtonText}>Thêm loài cá</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import styles from "../../style/NetDetailModal.styles";
|
||||||
|
|
||||||
|
interface FishSelectDropdownProps {
|
||||||
|
options: Model.FishSpeciesResponse[];
|
||||||
|
selectedFishId: number | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onSelect: (value: Model.FishSpeciesResponse) => void;
|
||||||
|
zIndex: number;
|
||||||
|
styleOverride?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FishSelectDropdown: React.FC<FishSelectDropdownProps> = ({
|
||||||
|
options,
|
||||||
|
selectedFishId,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
zIndex,
|
||||||
|
styleOverride,
|
||||||
|
}) => {
|
||||||
|
const dropdownStyle = styleOverride || styles.optionsList;
|
||||||
|
const findFishNameById = (id: number | null) => {
|
||||||
|
const fish = options.find((item) => item.id === id);
|
||||||
|
return fish?.name || "Chọn cá";
|
||||||
|
};
|
||||||
|
const [selectedFish, setSelectedFish] =
|
||||||
|
React.useState<Model.FishSpeciesResponse | null>(null);
|
||||||
|
return (
|
||||||
|
<View style={{ zIndex }}>
|
||||||
|
<TouchableOpacity style={styles.selectButton} onPress={onToggle}>
|
||||||
|
<Text style={styles.selectButtonText}>
|
||||||
|
{findFishNameById(selectedFishId)}
|
||||||
|
</Text>
|
||||||
|
<IconSymbol
|
||||||
|
name={isOpen ? "chevron.up" : "chevron.down"}
|
||||||
|
size={16}
|
||||||
|
color="#666"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isOpen && (
|
||||||
|
<ScrollView style={dropdownStyle} nestedScrollEnabled={true}>
|
||||||
|
{options.map((option, optIndex) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.id || optIndex}
|
||||||
|
style={styles.optionItem}
|
||||||
|
onPress={() => onSelect(option)}
|
||||||
|
>
|
||||||
|
<Text style={styles.optionText}>
|
||||||
|
{findFishNameById(option.id)}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import React from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import styles from "../../style/NetDetailModal.styles";
|
||||||
|
|
||||||
|
interface InfoSectionProps {
|
||||||
|
fishingLog?: Model.FishingLog;
|
||||||
|
stt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InfoSection: React.FC<InfoSectionProps> = ({
|
||||||
|
fishingLog,
|
||||||
|
stt,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (!fishingLog) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const infoItems = [
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.sttLabel"),
|
||||||
|
value: `${t("trip.infoSection.haulPrefix")} ${stt}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.statusLabel"),
|
||||||
|
value:
|
||||||
|
fishingLog.status === 1
|
||||||
|
? t("trip.infoSection.statusCompleted")
|
||||||
|
: t("trip.infoSection.statusPending"),
|
||||||
|
isStatus: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.startTimeLabel"),
|
||||||
|
value: fishingLog.start_at
|
||||||
|
? new Date(fishingLog.start_at).toLocaleString()
|
||||||
|
: t("trip.infoSection.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.endTimeLabel"),
|
||||||
|
value:
|
||||||
|
fishingLog.end_at.toString() !== "0001-01-01T00:00:00Z"
|
||||||
|
? new Date(fishingLog.end_at).toLocaleString()
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: "Vị trí hạ thu",
|
||||||
|
// value: fishingLog.viTriHaThu || "Chưa cập nhật",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// label: "Vị trí thu lưới",
|
||||||
|
// value: fishingLog.viTriThuLuoi || "Chưa cập nhật",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// label: "Độ sâu hạ thu",
|
||||||
|
// value: fishingLog.doSauHaThu || "Chưa cập nhật",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// label: "Độ sâu thu lưới",
|
||||||
|
// value: fishingLog.doSauThuLuoi || "Chưa cập nhật",
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
{infoItems.map((item, index) => (
|
||||||
|
<View key={index} style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{item.label}</Text>
|
||||||
|
{item.isStatus ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
item.value === "Đã hoàn thành"
|
||||||
|
? styles.statusBadgeCompleted
|
||||||
|
: styles.statusBadgeInProgress,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusBadgeText,
|
||||||
|
item.value === "Đã hoàn thành"
|
||||||
|
? styles.statusBadgeTextCompleted
|
||||||
|
: styles.statusBadgeTextInProgress,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{item.value}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import styles from "../../style/NetDetailModal.styles";
|
||||||
|
|
||||||
|
interface NotesSectionProps {
|
||||||
|
ghiChu?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotesSection: React.FC<NotesSectionProps> = ({ ghiChu }) => {
|
||||||
|
if (!ghiChu) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>Ghi chú</Text>
|
||||||
|
<Text style={styles.infoValue}>{ghiChu}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { CatchSectionHeader } from "./CatchSectionHeader";
|
||||||
|
export { FishCardForm } from "./FishCardForm";
|
||||||
|
export { FishCardHeader } from "./FishCardHeader";
|
||||||
|
export { FishCardList } from "./FishCardList";
|
||||||
|
export { FishSelectDropdown } from "./FishSelectDropdown";
|
||||||
|
export { InfoSection } from "./InfoSection";
|
||||||
|
export { NotesSection } from "./NotesSection";
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export default StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
backgroundColor: "#f8f9fa",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#e9ecef",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#007bff",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#333",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ccc",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#555",
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
rowGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
fishNameDropdown: {
|
||||||
|
// Custom styles if needed
|
||||||
|
},
|
||||||
|
optionsStatusFishList: {
|
||||||
|
// Custom styles if needed
|
||||||
|
},
|
||||||
|
optionsList: {
|
||||||
|
maxHeight: 150,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ccc",
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
position: "absolute",
|
||||||
|
top: 40,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
selectButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ccc",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
selectButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
optionItem: {
|
||||||
|
padding: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#eee",
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ddd",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
backgroundColor: "#dc3545",
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
removeButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#dc3545",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
buttonGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-around",
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
backgroundColor: "#007bff",
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
editButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
backgroundColor: "#28a745",
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: "#007bff",
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#6c757d",
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
addFishButton: {
|
||||||
|
backgroundColor: "#17a2b8",
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
addFishButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
242
components/tripInfo/modal/TripCostDetailModal.tsx
Normal file
242
components/tripInfo/modal/TripCostDetailModal.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import styles from "./style/TripCostDetailModal.styles";
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 🧩 Interface
|
||||||
|
// ---------------------------
|
||||||
|
interface TripCostDetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
data: Model.TripCost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 💰 Component Modal
|
||||||
|
// ---------------------------
|
||||||
|
const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editableData, setEditableData] = useState<Model.TripCost[]>(data);
|
||||||
|
|
||||||
|
// Cập nhật editableData khi props data thay đổi (API fetch xong)
|
||||||
|
useEffect(() => {
|
||||||
|
setEditableData(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const tongCong = editableData.reduce((sum, item) => sum + item.total_cost, 0);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
// TODO: Save data to backend
|
||||||
|
console.log("Saved data:", editableData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditableData(data); // Reset to original data
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (
|
||||||
|
index: number,
|
||||||
|
field: keyof Model.TripCost,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
setEditableData((prev) =>
|
||||||
|
prev.map((item, idx) => {
|
||||||
|
if (idx === index) {
|
||||||
|
const updated = { ...item, [field]: value };
|
||||||
|
// Recalculate total_cost
|
||||||
|
if (field === "amount" || field === "cost_per_unit") {
|
||||||
|
const amount =
|
||||||
|
Number(field === "amount" ? value : item.amount) || 0;
|
||||||
|
const costPerUnit =
|
||||||
|
Number(field === "cost_per_unit" ? value : item.cost_per_unit) ||
|
||||||
|
0;
|
||||||
|
updated.total_cost = amount * costPerUnit;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
keyboardVerticalOffset={60}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("trip.costDetailModal.title")}</Text>
|
||||||
|
<View style={styles.headerButtons}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCancel}
|
||||||
|
style={styles.cancelButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>
|
||||||
|
{t("trip.costDetailModal.cancel")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
style={styles.saveButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.costDetailModal.save")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleEdit}
|
||||||
|
style={styles.editButton}
|
||||||
|
>
|
||||||
|
<View style={styles.editIconButton}>
|
||||||
|
<IconSymbol
|
||||||
|
name="pencil"
|
||||||
|
size={28}
|
||||||
|
color="#fff"
|
||||||
|
weight="heavy"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<View style={styles.closeIconButton}>
|
||||||
|
<IconSymbol name="xmark" size={28} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{editableData.map((item, index) => (
|
||||||
|
<View key={index} style={styles.itemCard}>
|
||||||
|
{/* Loại */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.costType")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={item.type}
|
||||||
|
onChangeText={(value) => updateItem(index, "type", value)}
|
||||||
|
editable={isEditing}
|
||||||
|
placeholder={t("trip.costDetailModal.enterCostType")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Số lượng & Đơn vị */}
|
||||||
|
<View style={styles.rowGroup}>
|
||||||
|
<View
|
||||||
|
style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.quantity")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={String(item.amount ?? "")}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
updateItem(index, "amount", value)
|
||||||
|
}
|
||||||
|
editable={isEditing}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.unit")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={item.unit}
|
||||||
|
onChangeText={(value) => updateItem(index, "unit", value)}
|
||||||
|
editable={isEditing}
|
||||||
|
placeholder={t("trip.costDetailModal.placeholder")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Chi phí/đơn vị */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.costPerUnit")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={String(item.cost_per_unit ?? "")}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
updateItem(index, "cost_per_unit", value)
|
||||||
|
}
|
||||||
|
editable={isEditing}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tổng chi phí */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.totalCost")}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.totalContainer}>
|
||||||
|
<Text style={styles.totalText}>
|
||||||
|
{item.total_cost.toLocaleString()}{" "}
|
||||||
|
{t("trip.costDetailModal.vnd")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer Total */}
|
||||||
|
<View style={styles.footerTotal}>
|
||||||
|
<Text style={styles.footerLabel}>
|
||||||
|
{t("trip.costDetailModal.total")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.footerAmount}>
|
||||||
|
{tongCong.toLocaleString()} {t("trip.costDetailModal.vnd")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCostDetailModal;
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#eee",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#000",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: "#FF3B30",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: "#007bff",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
fishCard: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
fishCardHeaderContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
fishCardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
fishCardSubtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: "#ff6600",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#333",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ddd",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
inputDisabled: {
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
color: "#999",
|
||||||
|
borderColor: "#eee",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#dc3545",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
backgroundColor: "#dc3545",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
removeButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
footerSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: "#eee",
|
||||||
|
},
|
||||||
|
saveButtonLarge: {
|
||||||
|
backgroundColor: "#007bff",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButtonLargeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#999",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default styles;
|
||||||
69
components/tripInfo/modal/style/CrewDetailModal.styles.ts
Normal file
69
components/tripInfo/modal/style/CrewDetailModal.styles.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#eee",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#000",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: "#FF3B30",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
infoCard: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 35,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#000",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default styles;
|
||||||
292
components/tripInfo/modal/style/NetDetailModal.styles.ts
Normal file
292
components/tripInfo/modal/style/NetDetailModal.styles.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#eee",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#000",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: "#FF3B30",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
infoCard: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 35,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#000",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
statusBadgeCompleted: {
|
||||||
|
backgroundColor: "#e8f5e9",
|
||||||
|
},
|
||||||
|
statusBadgeInProgress: {
|
||||||
|
backgroundColor: "#fff3e0",
|
||||||
|
},
|
||||||
|
statusBadgeText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
statusBadgeTextCompleted: {
|
||||||
|
color: "#2e7d32",
|
||||||
|
},
|
||||||
|
statusBadgeTextInProgress: {
|
||||||
|
color: "#f57c00",
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
editIconButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: "#007AFF",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
totalCatchText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#007AFF",
|
||||||
|
},
|
||||||
|
fishCard: {
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 12,
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
rowGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: "#000",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
selectButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
selectButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
optionsList: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 46,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
maxHeight: 100,
|
||||||
|
zIndex: 1000,
|
||||||
|
elevation: 5,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
|
optionItem: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
optionsStatusFishList: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
maxHeight: 120,
|
||||||
|
zIndex: 1000,
|
||||||
|
elevation: 5,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
|
fishNameDropdown: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 46,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
maxHeight: 180,
|
||||||
|
zIndex: 1000,
|
||||||
|
elevation: 5,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
|
fishCardHeaderContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 5,
|
||||||
|
},
|
||||||
|
fishCardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
fishCardSubtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: "#ff6600",
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
addFishButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
addFishButtonContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
addFishButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default styles;
|
||||||
153
components/tripInfo/modal/style/TripCostDetailModal.styles.ts
Normal file
153
components/tripInfo/modal/style/TripCostDetailModal.styles.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: "#FF3B30",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#eee",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#000",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
editIconButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: "#007AFF",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
itemCard: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
rowGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#007AFF",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: "#000",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
inputDisabled: {
|
||||||
|
borderColor: "#ddd",
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
totalContainer: {
|
||||||
|
backgroundColor: "#fff5e6",
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ffd699",
|
||||||
|
},
|
||||||
|
totalText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#ff6600",
|
||||||
|
},
|
||||||
|
footerTotal: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 50,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
footerLabel: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#007bff",
|
||||||
|
},
|
||||||
|
footerAmount: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#ff6600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default styles;
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
export default StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginVertical: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#fff",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
totalCollapsed: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
},
|
|
||||||
tableHeader: {
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
cellWrapper: {
|
|
||||||
flex: 1.5,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
left: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
right: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
color: "#007bff",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerTotal: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "800",
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
color: "#007AFF",
|
|
||||||
textDecorationLine: "underline",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
export default StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginVertical: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#fff",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
totalCollapsed: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
paddingLeft: 15,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
},
|
|
||||||
left: {
|
|
||||||
textAlign: "left",
|
|
||||||
},
|
|
||||||
right: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
tableHeader: {
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
color: "#007bff",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerTotal: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "800",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
export default StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginVertical: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#eee",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 1,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
totalCollapsed: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
},
|
|
||||||
tableHeader: {
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
sttCell: {
|
|
||||||
flex: 0.3,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
textAlign: "center",
|
|
||||||
paddingLeft: 10,
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
statusContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
statusDot: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: "#2ecc71",
|
|
||||||
marginRight: 6,
|
|
||||||
},
|
|
||||||
statusText: {
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#4a90e2",
|
|
||||||
textDecorationLine: "underline",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
margin: 16,
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderColor: "#ddd",
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingLeft: 15,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 15,
|
|
||||||
},
|
|
||||||
left: {
|
|
||||||
textAlign: "left",
|
|
||||||
},
|
|
||||||
right: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
backgroundColor: "#f8f8f8",
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
marginTop: 6,
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#007bff",
|
|
||||||
},
|
|
||||||
total: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
viewDetailButton: {
|
|
||||||
marginTop: 12,
|
|
||||||
paddingVertical: 8,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
viewDetailText: {
|
|
||||||
color: "#007AFF",
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "600",
|
|
||||||
textDecorationLine: "underline",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default styles;
|
|
||||||
175
components/tripInfo/style/createTableStyles.ts
Normal file
175
components/tripInfo/style/createTableStyles.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
export function createTableStyles(colorScheme: ColorScheme) {
|
||||||
|
const colors = Colors[colorScheme];
|
||||||
|
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
shadowColor: colors.text,
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
totalCollapsed: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
rowHorizontal: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
paddingLeft: 15,
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
headerCell: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
headerCellLeft: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
cellLeft: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
cellRight: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
cellWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
footerTotal: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
sttCell: {
|
||||||
|
flex: 0.3,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
paddingLeft: 10,
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
statusDotPending: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.primary,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: colors.primary,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
viewDetailButton: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
viewDetailText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
footerRow: {
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TableStyles = ReturnType<typeof createTableStyles>;
|
||||||
@@ -23,11 +23,14 @@ const MAPPING = {
|
|||||||
"chevron.right": "chevron-right",
|
"chevron.right": "chevron-right",
|
||||||
"ferry.fill": "directions-boat",
|
"ferry.fill": "directions-boat",
|
||||||
"map.fill": "map",
|
"map.fill": "map",
|
||||||
"arrowshape.down.fill": "arrow-drop-down",
|
"chevron.down": "arrow-drop-down",
|
||||||
"arrowshape.up.fill": "arrow-drop-up",
|
"chevron.up": "arrow-drop-up",
|
||||||
"exclamationmark.triangle.fill": "warning",
|
"exclamationmark.triangle.fill": "warning",
|
||||||
"book.closed.fill": "book",
|
"book.closed.fill": "book",
|
||||||
"dot.radiowaves.left.and.right": "sensors",
|
"dot.radiowaves.left.and.right": "sensors",
|
||||||
|
xmark: "close",
|
||||||
|
pencil: "edit",
|
||||||
|
trash: "delete",
|
||||||
} as IconMapping;
|
} as IconMapping;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
578
components/ui/modal.tsx
Normal file
578
components/ui/modal.tsx
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
Modal as RNModal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface ModalProps {
|
||||||
|
/** Whether the modal dialog is visible or not */
|
||||||
|
open?: boolean;
|
||||||
|
/** The modal dialog's title */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Whether a close (x) button is visible on top right or not */
|
||||||
|
closable?: boolean;
|
||||||
|
/** Custom close icon */
|
||||||
|
closeIcon?: ReactNode;
|
||||||
|
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
|
||||||
|
maskClosable?: boolean;
|
||||||
|
/** Centered Modal */
|
||||||
|
centered?: boolean;
|
||||||
|
/** Width of the modal dialog */
|
||||||
|
width?: number | string;
|
||||||
|
/** Whether to apply loading visual effect for OK button or not */
|
||||||
|
confirmLoading?: boolean;
|
||||||
|
/** Text of the OK button */
|
||||||
|
okText?: string;
|
||||||
|
/** Text of the Cancel button */
|
||||||
|
cancelText?: string;
|
||||||
|
/** Button type of the OK button */
|
||||||
|
okType?: "primary" | "default" | "dashed" | "text" | "link";
|
||||||
|
/** Footer content, set as footer={null} when you don't need default buttons */
|
||||||
|
footer?: ReactNode | null;
|
||||||
|
/** Whether show mask or not */
|
||||||
|
mask?: boolean;
|
||||||
|
/** The z-index of the Modal */
|
||||||
|
zIndex?: number;
|
||||||
|
/** Specify a function that will be called when a user clicks the OK button */
|
||||||
|
onOk?: (e?: any) => void | Promise<void>;
|
||||||
|
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
|
||||||
|
onCancel?: (e?: any) => void;
|
||||||
|
/** Callback when the animation ends when Modal is turned on and off */
|
||||||
|
afterOpenChange?: (open: boolean) => void;
|
||||||
|
/** Specify a function that will be called when modal is closed completely */
|
||||||
|
afterClose?: () => void;
|
||||||
|
/** Custom className */
|
||||||
|
className?: string;
|
||||||
|
/** Modal body content */
|
||||||
|
children?: ReactNode;
|
||||||
|
/** Whether to unmount child components on close */
|
||||||
|
destroyOnClose?: boolean;
|
||||||
|
/** The ok button props */
|
||||||
|
okButtonProps?: any;
|
||||||
|
/** The cancel button props */
|
||||||
|
cancelButtonProps?: any;
|
||||||
|
/** Whether support press esc to close */
|
||||||
|
keyboard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmModalProps extends Omit<ModalProps, "open"> {
|
||||||
|
/** Type of the confirm modal */
|
||||||
|
type?: "info" | "success" | "error" | "warning" | "confirm";
|
||||||
|
/** Content */
|
||||||
|
content?: ReactNode;
|
||||||
|
/** Custom icon */
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal Component
|
||||||
|
const Modal: React.FC<ModalProps> & {
|
||||||
|
info: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
success: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
error: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
useModal: () => [
|
||||||
|
{
|
||||||
|
info: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
success: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
error: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
},
|
||||||
|
ReactNode
|
||||||
|
];
|
||||||
|
} = ({
|
||||||
|
open = false,
|
||||||
|
title,
|
||||||
|
closable = true,
|
||||||
|
closeIcon,
|
||||||
|
maskClosable = true,
|
||||||
|
centered = false,
|
||||||
|
width = 520,
|
||||||
|
confirmLoading = false,
|
||||||
|
okText = "OK",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
okType = "primary",
|
||||||
|
footer,
|
||||||
|
mask = true,
|
||||||
|
zIndex = 1000,
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
afterOpenChange,
|
||||||
|
afterClose,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
destroyOnClose = false,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
keyboard = true,
|
||||||
|
}) => {
|
||||||
|
const [visible, setVisible] = useState(open);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(open);
|
||||||
|
if (afterOpenChange) {
|
||||||
|
afterOpenChange(open);
|
||||||
|
}
|
||||||
|
}, [open, afterOpenChange]);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
if (onOk) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onOk();
|
||||||
|
// Không tự động đóng modal - để parent component quyết định
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Modal onOk error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
// Nếu không có onCancel, tự động đóng modal
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaskPress = () => {
|
||||||
|
if (maskClosable) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
if (keyboard) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible && afterClose) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
afterClose();
|
||||||
|
}, 300); // Wait for animation to complete
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible, afterClose]);
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
if (footer === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer !== undefined) {
|
||||||
|
return <View style={styles.footer}>{footer}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.cancelButton]}
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={loading || confirmLoading}
|
||||||
|
{...cancelButtonProps}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>{cancelText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
styles.okButton,
|
||||||
|
okType === "primary" && styles.primaryButton,
|
||||||
|
(loading || confirmLoading) && styles.disabledButton,
|
||||||
|
]}
|
||||||
|
onPress={handleOk}
|
||||||
|
disabled={loading || confirmLoading}
|
||||||
|
{...okButtonProps}
|
||||||
|
>
|
||||||
|
{loading || confirmLoading ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.okButtonText}>{okText}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalWidth =
|
||||||
|
typeof width === "number" ? width : Dimensions.get("window").width * 0.9;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RNModal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleRequestClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.overlay,
|
||||||
|
centered && styles.centered,
|
||||||
|
{ zIndex },
|
||||||
|
!mask && styles.noMask,
|
||||||
|
]}
|
||||||
|
onPress={handleMaskPress}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.modal, { width: modalWidth, maxWidth: "90%" }]}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{(title || closable) && (
|
||||||
|
<View style={styles.header}>
|
||||||
|
{title && (
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{closable && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={handleCancel}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
{closeIcon || (
|
||||||
|
<Ionicons name="close" size={24} color="#666" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<View style={styles.body}>
|
||||||
|
{(!destroyOnClose || visible) && children}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{renderFooter()}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</RNModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirm Modal Component
|
||||||
|
const ConfirmModal: React.FC<
|
||||||
|
ConfirmModalProps & { visible: boolean; onClose: () => void }
|
||||||
|
> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
type = "confirm",
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
icon,
|
||||||
|
okText = "OK",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
...restProps
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
if (icon !== undefined) return icon;
|
||||||
|
|
||||||
|
const iconProps = { size: 24, style: { marginRight: 12 } };
|
||||||
|
switch (type) {
|
||||||
|
case "info":
|
||||||
|
return (
|
||||||
|
<Ionicons name="information-circle" color="#1890ff" {...iconProps} />
|
||||||
|
);
|
||||||
|
case "success":
|
||||||
|
return (
|
||||||
|
<Ionicons name="checkmark-circle" color="#52c41a" {...iconProps} />
|
||||||
|
);
|
||||||
|
case "error":
|
||||||
|
return <Ionicons name="close-circle" color="#ff4d4f" {...iconProps} />;
|
||||||
|
case "warning":
|
||||||
|
return <Ionicons name="warning" color="#faad14" {...iconProps} />;
|
||||||
|
default:
|
||||||
|
return <Ionicons name="help-circle" color="#1890ff" {...iconProps} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
if (onOk) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onOk();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Confirm modal onOk error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={visible}
|
||||||
|
title={title}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={type === "confirm" ? handleCancel : undefined}
|
||||||
|
okText={okText}
|
||||||
|
cancelText={cancelText}
|
||||||
|
confirmLoading={loading}
|
||||||
|
footer={
|
||||||
|
type === "confirm" ? undefined : (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.okButton, styles.primaryButton]}
|
||||||
|
onPress={handleOk}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.okButtonText}>{okText}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<View style={styles.confirmContent}>
|
||||||
|
{getIcon()}
|
||||||
|
<Text style={styles.confirmText}>{content}</Text>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal Instance
|
||||||
|
export interface ModalInstance {
|
||||||
|
destroy: () => void;
|
||||||
|
update: (config: ConfirmModalProps) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container for imperatively created modals - Not used in React Native
|
||||||
|
// Static methods will return instance but won't render imperatively
|
||||||
|
// Use Modal.useModal() hook for proper context support
|
||||||
|
|
||||||
|
const createConfirmModal = (config: ConfirmModalProps): ModalInstance => {
|
||||||
|
console.warn(
|
||||||
|
"Modal static methods are not fully supported in React Native. Please use Modal.useModal() hook for better context support."
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
console.warn(
|
||||||
|
"Modal.destroy() called but static modals are not supported in React Native"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
update: (newConfig: ConfirmModalProps) => {
|
||||||
|
console.warn(
|
||||||
|
"Modal.update() called but static modals are not supported in React Native"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
Modal.info = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "info" });
|
||||||
|
Modal.success = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "success" });
|
||||||
|
Modal.error = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "error" });
|
||||||
|
Modal.warning = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "warning" });
|
||||||
|
Modal.confirm = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "confirm" });
|
||||||
|
|
||||||
|
// useModal hook
|
||||||
|
Modal.useModal = () => {
|
||||||
|
const [modals, setModals] = useState<ReactNode[]>([]);
|
||||||
|
|
||||||
|
const createModal = (
|
||||||
|
config: ConfirmModalProps,
|
||||||
|
type: ConfirmModalProps["type"]
|
||||||
|
) => {
|
||||||
|
const id = `modal-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
setModals((prev) => prev.filter((modal: any) => modal.key !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (newConfig: ConfirmModalProps) => {
|
||||||
|
setModals((prev) =>
|
||||||
|
prev.map((modal: any) =>
|
||||||
|
modal.key === id ? (
|
||||||
|
<ConfirmModal
|
||||||
|
key={id}
|
||||||
|
visible={true}
|
||||||
|
onClose={destroy}
|
||||||
|
{...config}
|
||||||
|
{...newConfig}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
modal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalElement = (
|
||||||
|
<ConfirmModal
|
||||||
|
key={id}
|
||||||
|
visible={true}
|
||||||
|
onClose={destroy}
|
||||||
|
{...config}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
setModals((prev) => [...prev, modalElement]);
|
||||||
|
|
||||||
|
return { destroy, update };
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalMethods = {
|
||||||
|
info: (props: ConfirmModalProps) => createModal(props, "info"),
|
||||||
|
success: (props: ConfirmModalProps) => createModal(props, "success"),
|
||||||
|
error: (props: ConfirmModalProps) => createModal(props, "error"),
|
||||||
|
warning: (props: ConfirmModalProps) => createModal(props, "warning"),
|
||||||
|
confirm: (props: ConfirmModalProps) => createModal(props, "confirm"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextHolder = <>{modals}</>;
|
||||||
|
|
||||||
|
return [modalMethods, contextHolder];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
paddingTop: 100,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
|
noMask: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
maxHeight: "90%",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#000",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
minWidth: 70,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#d9d9d9",
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: "#000",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
okButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
},
|
||||||
|
okButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
confirmContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#000",
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
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;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { removeStorageItem } from "@/utils/storage";
|
||||||
import { Router } from "expo-router";
|
import { Router } from "expo-router";
|
||||||
|
|
||||||
let routerInstance: Router | null = null;
|
let routerInstance: Router | null = null;
|
||||||
@@ -14,7 +16,13 @@ export const setRouterInstance = (router: Router) => {
|
|||||||
*/
|
*/
|
||||||
export const handle401 = () => {
|
export const handle401 = () => {
|
||||||
if (routerInstance) {
|
if (routerInstance) {
|
||||||
(routerInstance as any).replace("/login");
|
removeStorageItem(TOKEN);
|
||||||
|
removeStorageItem(DOMAIN);
|
||||||
|
// Cancel all pending requests to prevent further API calls
|
||||||
|
if (typeof window !== "undefined" && (window as any).axiosAbortController) {
|
||||||
|
(window as any).axiosAbortController.abort();
|
||||||
|
}
|
||||||
|
routerInstance.navigate("/auth/login");
|
||||||
} else {
|
} else {
|
||||||
console.warn("Router instance not set, cannot redirect to login");
|
console.warn("Router instance not set, cannot redirect to login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { TOKEN } from "@/constants";
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { showErrorToast } from "@/services/toast_service";
|
||||||
import { getStorageItem } from "@/utils/storage";
|
import { getStorageItem } from "@/utils/storage";
|
||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import { showToastError } from "./toast";
|
import { handle401 } from "./auth";
|
||||||
|
|
||||||
const codeMessage = {
|
const codeMessage = {
|
||||||
200: "The server successfully returned the requested data。",
|
200: "The server successfully returned the requested data。",
|
||||||
@@ -37,9 +38,15 @@ api.interceptors.request.use(
|
|||||||
async (config) => {
|
async (config) => {
|
||||||
// Thêm auth token nếu có
|
// Thêm auth token nếu có
|
||||||
const token = await getStorageItem(TOKEN);
|
const token = await getStorageItem(TOKEN);
|
||||||
|
const domain = await getStorageItem(DOMAIN);
|
||||||
|
if (domain) {
|
||||||
|
config.baseURL = `http://${domain}`;
|
||||||
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `${token}`;
|
config.headers.Authorization = `${token}`;
|
||||||
}
|
}
|
||||||
|
// console.log("Domain Request: ", config.baseURL);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -56,7 +63,9 @@ api.interceptors.response.use(
|
|||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
const networkErrorMsg =
|
const networkErrorMsg =
|
||||||
error.message || "Network error - please check connection";
|
error.message || "Network error - please check connection";
|
||||||
showToastError("Lỗi kết nối", networkErrorMsg);
|
showErrorToast("Lỗi kết nối");
|
||||||
|
console.error("Response Network Error: ", networkErrorMsg);
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +78,9 @@ api.interceptors.response.use(
|
|||||||
statusText ||
|
statusText ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
|
|
||||||
showToastError(`Lỗi ${status}`, errMsg);
|
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// handle401();
|
handle401();
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import Toast from "react-native-toast-message";
|
|
||||||
|
|
||||||
export enum ToastType {
|
|
||||||
SUCCESS = "success",
|
|
||||||
ERROR = "error",
|
|
||||||
WARNING = "error", // react-native-toast-message không có 'warning', dùng 'error'
|
|
||||||
INFO = "info",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success toast
|
|
||||||
*/
|
|
||||||
export const showToastSuccess = (message: string, title?: string): void => {
|
|
||||||
Toast.show({
|
|
||||||
type: "success",
|
|
||||||
text1: title || "Success",
|
|
||||||
text2: message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error toast
|
|
||||||
*/
|
|
||||||
export const showToastError = (message: string, title?: string): void => {
|
|
||||||
Toast.show({
|
|
||||||
type: ToastType.ERROR,
|
|
||||||
text1: title || ToastType.ERROR,
|
|
||||||
text2: message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Info toast
|
|
||||||
*/
|
|
||||||
export const showToastInfo = (message: string, title?: string): void => {
|
|
||||||
Toast.show({
|
|
||||||
type: ToastType.INFO,
|
|
||||||
text1: title || ToastType.INFO,
|
|
||||||
text2: message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Warning toast
|
|
||||||
*/
|
|
||||||
export const showToastWarning = (message: string, title?: string): void => {
|
|
||||||
Toast.show({
|
|
||||||
type: ToastType.WARNING,
|
|
||||||
text1: title || ToastType.WARNING,
|
|
||||||
text2: message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default toast
|
|
||||||
*/
|
|
||||||
export const showToastDefault = (message: string, title?: string): void => {
|
|
||||||
Toast.show({
|
|
||||||
type: ToastType.INFO,
|
|
||||||
text1: title || ToastType.INFO,
|
|
||||||
text2: message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom toast với type tùy chọn
|
|
||||||
*/
|
|
||||||
export const show = (
|
|
||||||
message: string,
|
|
||||||
type: ToastType,
|
|
||||||
title?: string
|
|
||||||
): void => {
|
|
||||||
const titleText = title || type.charAt(0).toUpperCase() + type.slice(1);
|
|
||||||
Toast.show({
|
|
||||||
type,
|
|
||||||
text1: titleText,
|
|
||||||
text2: message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
214
config/toast.tsx
Normal file
214
config/toast.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import {
|
||||||
|
BaseToast,
|
||||||
|
BaseToastProps,
|
||||||
|
SuccessToast,
|
||||||
|
} from "react-native-toast-message";
|
||||||
|
|
||||||
|
export const Colors: any = {
|
||||||
|
light: {
|
||||||
|
text: "#000",
|
||||||
|
back: "#ffffff",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: "#ffffff",
|
||||||
|
back: "#2B2D2E",
|
||||||
|
},
|
||||||
|
default: "#3498db",
|
||||||
|
info: "#3498db",
|
||||||
|
success: "#07bc0c",
|
||||||
|
warn: {
|
||||||
|
background: "#ffffff",
|
||||||
|
text: "black",
|
||||||
|
iconColor: "#f1c40f",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
background: "#ffffff",
|
||||||
|
text: "black",
|
||||||
|
iconColor: "#e74c3c",
|
||||||
|
},
|
||||||
|
textDefault: "#4c4c4c",
|
||||||
|
textDark: "black",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastConfig = {
|
||||||
|
success: (props: BaseToastProps) => (
|
||||||
|
<SuccessToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.success,
|
||||||
|
backgroundColor: Colors.success,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 40,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="check-circle" size={30} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
default: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.default,
|
||||||
|
backgroundColor: Colors.default,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="info" size={30} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
info: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.info,
|
||||||
|
backgroundColor: Colors.info,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="info-outline" size={30} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
warn: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.warn.background,
|
||||||
|
backgroundColor: Colors.warn.background,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: Colors.warn.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#333",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="warning"
|
||||||
|
size={30}
|
||||||
|
color={Colors.warn.iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
error: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.error.background,
|
||||||
|
backgroundColor: Colors.error.background,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: Colors.error.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="error"
|
||||||
|
size={30}
|
||||||
|
color={Colors.error.iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const TOKEN = "token";
|
export const TOKEN = "token";
|
||||||
export const BASE_URL = "https://sgw-device.gms.vn";
|
export const DOMAIN = "domain";
|
||||||
export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
|
export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
|
||||||
export const MAP_POLYLINE_BAN = "ban-polyline";
|
export const MAP_POLYLINE_BAN = "ban-polyline";
|
||||||
export const MAP_POLYGON_BAN = "ban-polygon";
|
export const MAP_POLYGON_BAN = "ban-polygon";
|
||||||
@@ -15,6 +15,12 @@ export const DARK_THEME = "dark";
|
|||||||
export const ROUTE_LOGIN = "/login";
|
export const ROUTE_LOGIN = "/login";
|
||||||
export const ROUTE_HOME = "/map";
|
export const ROUTE_HOME = "/map";
|
||||||
export const ROUTE_TRIP = "/trip";
|
export const ROUTE_TRIP = "/trip";
|
||||||
|
// Event Emitters
|
||||||
|
export const EVENT_GPS_DATA = "GPS_DATA_EVENT";
|
||||||
|
export const EVENT_ALARM_DATA = "ALARM_DATA_EVENT";
|
||||||
|
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
|
||||||
|
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
|
||||||
|
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
|
||||||
|
|
||||||
// Entity Contants
|
// Entity Contants
|
||||||
export const ENTITY = {
|
export const ENTITY = {
|
||||||
@@ -38,5 +44,3 @@ export const API_UPDATE_FISHING_LOGS = "/api/sgw/fishingLog";
|
|||||||
export const API_SOS = "/api/sgw/sos";
|
export const API_SOS = "/api/sgw/sos";
|
||||||
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
||||||
export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
||||||
|
|
||||||
// Smatec
|
|
||||||
|
|||||||
@@ -3,51 +3,82 @@
|
|||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
const tintColorLight = '#0a7ea4';
|
const tintColorLight = "#0a7ea4";
|
||||||
const tintColorDark = '#fff';
|
const tintColorDark = "#fff";
|
||||||
|
|
||||||
export const Colors = {
|
export const Colors = {
|
||||||
light: {
|
light: {
|
||||||
text: '#11181C',
|
text: "#11181C",
|
||||||
background: '#fff',
|
textSecondary: "#687076",
|
||||||
|
background: "#fff",
|
||||||
|
backgroundSecondary: "#f5f5f5",
|
||||||
|
surface: "#ffffff",
|
||||||
|
surfaceSecondary: "#f8f9fa",
|
||||||
tint: tintColorLight,
|
tint: tintColorLight,
|
||||||
icon: '#687076',
|
primary: "#007AFF",
|
||||||
tabIconDefault: '#687076',
|
secondary: "#5AC8FA",
|
||||||
|
success: "#34C759",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF3B30",
|
||||||
|
icon: "#687076",
|
||||||
|
iconSecondary: "#8E8E93",
|
||||||
|
border: "#C6C6C8",
|
||||||
|
separator: "#E5E5E7",
|
||||||
|
tabIconDefault: "#687076",
|
||||||
tabIconSelected: tintColorLight,
|
tabIconSelected: tintColorLight,
|
||||||
|
card: "#ffffff",
|
||||||
|
notification: "#FF3B30",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
text: '#ECEDEE',
|
text: "#ECEDEE",
|
||||||
background: '#151718',
|
textSecondary: "#8E8E93",
|
||||||
|
background: "#000000",
|
||||||
|
backgroundSecondary: "#1C1C1E",
|
||||||
|
surface: "#1C1C1E",
|
||||||
|
surfaceSecondary: "#2C2C2E",
|
||||||
tint: tintColorDark,
|
tint: tintColorDark,
|
||||||
icon: '#9BA1A6',
|
primary: "#0A84FF",
|
||||||
tabIconDefault: '#9BA1A6',
|
secondary: "#64D2FF",
|
||||||
|
success: "#30D158",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF453A",
|
||||||
|
icon: "#8E8E93",
|
||||||
|
iconSecondary: "#636366",
|
||||||
|
border: "#38383A",
|
||||||
|
separator: "#38383A",
|
||||||
|
tabIconDefault: "#8E8E93",
|
||||||
tabIconSelected: tintColorDark,
|
tabIconSelected: tintColorDark,
|
||||||
|
card: "#1C1C1E",
|
||||||
|
notification: "#FF453A",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ColorName = keyof typeof Colors.light;
|
||||||
|
|
||||||
export const Fonts = Platform.select({
|
export const Fonts = Platform.select({
|
||||||
ios: {
|
ios: {
|
||||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||||
sans: 'system-ui',
|
sans: "system-ui",
|
||||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||||
serif: 'ui-serif',
|
serif: "ui-serif",
|
||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||||
rounded: 'ui-rounded',
|
rounded: "ui-rounded",
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||||
mono: 'ui-monospace',
|
mono: "ui-monospace",
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
sans: 'normal',
|
sans: "normal",
|
||||||
serif: 'serif',
|
serif: "serif",
|
||||||
rounded: 'normal',
|
rounded: "normal",
|
||||||
mono: 'monospace',
|
mono: "monospace",
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
serif: "Georgia, 'Times New Roman', serif",
|
||||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
rounded:
|
||||||
|
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
API_GET_GPS,
|
API_GET_GPS,
|
||||||
API_PATH_ENTITIES,
|
API_PATH_ENTITIES,
|
||||||
API_PATH_SHIP_TRACK_POINTS,
|
API_PATH_SHIP_TRACK_POINTS,
|
||||||
|
API_SOS,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
import { transformEntityResponse } from "@/utils/tranform";
|
import { transformEntityResponse } from "@/utils/tranform";
|
||||||
|
|
||||||
export async function queryGpsData() {
|
export async function queryGpsData() {
|
||||||
return api.get<Model.GPSResonse>(API_GET_GPS);
|
return api.get<Model.GPSResponse>(API_GET_GPS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryAlarm() {
|
export async function queryAlarm() {
|
||||||
@@ -23,3 +24,14 @@ export async function queryEntities(): Promise<Model.TransformedEntity[]> {
|
|||||||
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
|
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
|
||||||
return response.data.map(transformEntityResponse);
|
return response.data.map(transformEntityResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function queryGetSos() {
|
||||||
|
return await api.get<Model.SosResponse>(API_SOS);
|
||||||
|
}
|
||||||
|
export async function queryDeleteSos() {
|
||||||
|
return await api.delete<Model.SosResponse>(API_SOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function querySendSosMessage(message: string) {
|
||||||
|
return await api.put<Model.SosRequest>(API_SOS, { message });
|
||||||
|
}
|
||||||
|
|||||||
6
controller/FishController.ts
Normal file
6
controller/FishController.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_GET_FISH } from "@/constants";
|
||||||
|
|
||||||
|
export async function queryFish() {
|
||||||
|
return api.get<Model.FishSpeciesResponse[]>(API_GET_FISH);
|
||||||
|
}
|
||||||
23
controller/TripController.ts
Normal file
23
controller/TripController.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import {
|
||||||
|
API_GET_TRIP,
|
||||||
|
API_HAUL_HANDLE,
|
||||||
|
API_UPDATE_FISHING_LOGS,
|
||||||
|
API_UPDATE_TRIP_STATUS,
|
||||||
|
} from "@/constants";
|
||||||
|
|
||||||
|
export async function queryTrip() {
|
||||||
|
return api.get<Model.Trip>(API_GET_TRIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUpdateTripState(body: Model.TripUpdateStateRequest) {
|
||||||
|
return api.put(API_UPDATE_TRIP_STATUS, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryStartNewHaul(body: Model.NewFishingLogRequest) {
|
||||||
|
return api.put(API_HAUL_HANDLE, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUpdateFishingLogs(body: Model.FishingLog) {
|
||||||
|
return api.put(API_UPDATE_FISHING_LOGS, body);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as AuthController from "./AuthController";
|
import * as AuthController from "./AuthController";
|
||||||
import * as DeviceController from "./DeviceController";
|
import * as DeviceController from "./DeviceController";
|
||||||
import * as MapController from "./MapController";
|
import * as MapController from "./MapController";
|
||||||
export { AuthController, DeviceController, MapController };
|
import * as TripController from "./TripController";
|
||||||
|
export { AuthController, DeviceController, MapController, TripController };
|
||||||
|
|||||||
131
controller/typings.d.ts
vendored
131
controller/typings.d.ts
vendored
@@ -7,7 +7,7 @@ declare namespace Model {
|
|||||||
token?: string;
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GPSResonse {
|
interface GPSResponse {
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
s: number;
|
s: number;
|
||||||
@@ -80,4 +80,133 @@ declare namespace Model {
|
|||||||
geom_point?: string;
|
geom_point?: string;
|
||||||
geom_radius?: number;
|
geom_radius?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SosRequest {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SosResponse {
|
||||||
|
active: boolean;
|
||||||
|
message?: string;
|
||||||
|
started_at?: number;
|
||||||
|
}
|
||||||
|
// Trip
|
||||||
|
interface Trip {
|
||||||
|
id: string;
|
||||||
|
ship_id: string;
|
||||||
|
ship_length: number;
|
||||||
|
vms_id: string;
|
||||||
|
name: string;
|
||||||
|
fishing_gears: FishingGear[]; // Dụng cụ đánh cá
|
||||||
|
crews?: TripCrews[]; // Thuyền viên
|
||||||
|
departure_time: string; // ISO datetime string
|
||||||
|
departure_port_id: number;
|
||||||
|
arrival_time: string; // ISO datetime string
|
||||||
|
arrival_port_id: number;
|
||||||
|
fishing_ground_codes: number[];
|
||||||
|
total_catch_weight: number | null;
|
||||||
|
total_species_caught: number | null;
|
||||||
|
trip_cost: TripCost[]; // Chi phí chuyến đi
|
||||||
|
trip_status: number;
|
||||||
|
approved_by: string;
|
||||||
|
notes: string | null;
|
||||||
|
fishing_logs: FishingLog[] | null; // tuỳ dữ liệu chi tiết có thể định nghĩa thêm
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dụng cụ đánh cá
|
||||||
|
interface FishingGear {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
// Thuyền viên
|
||||||
|
interface TripCrews {
|
||||||
|
TripID: string;
|
||||||
|
PersonalID: string;
|
||||||
|
role: string;
|
||||||
|
joined_at: Date;
|
||||||
|
left_at: Date | null;
|
||||||
|
note: string | null;
|
||||||
|
Person: TripCrewPerson;
|
||||||
|
}
|
||||||
|
interface TripCrewPerson {
|
||||||
|
personal_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: Date; // ISO string (có thể chuyển sang Date nếu parse trước)
|
||||||
|
note: string;
|
||||||
|
address: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
// Chi phí chuyến đi
|
||||||
|
interface TripCost {
|
||||||
|
type: string;
|
||||||
|
unit: string;
|
||||||
|
amount: number;
|
||||||
|
total_cost: number;
|
||||||
|
cost_per_unit: number;
|
||||||
|
}
|
||||||
|
// Thông tin mẻ lưới
|
||||||
|
interface FishingLog {
|
||||||
|
fishing_log_id: string;
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date; // ISO datetime
|
||||||
|
end_at: Date; // ISO datetime
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
haul_lat: number;
|
||||||
|
haul_lon: number;
|
||||||
|
status: number;
|
||||||
|
weather_description: string;
|
||||||
|
info?: FishingLogInfo[]; // Thông tin cá
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
// Thông tin cá
|
||||||
|
interface FishingLogInfo {
|
||||||
|
fish_species_id?: number;
|
||||||
|
fish_name?: string;
|
||||||
|
catch_number?: number;
|
||||||
|
catch_unit?: string;
|
||||||
|
fish_size?: number;
|
||||||
|
fish_rarity?: number;
|
||||||
|
fish_condition?: string;
|
||||||
|
gear_usage?: string;
|
||||||
|
}
|
||||||
|
interface NewFishingLogRequest {
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date; // ISO datetime
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
weather_description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripUpdateStateRequest {
|
||||||
|
status: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
//Fish
|
||||||
|
interface FishSpeciesResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
scientific_name: string;
|
||||||
|
group_name: string;
|
||||||
|
species_code: string;
|
||||||
|
note: string;
|
||||||
|
default_unit: string;
|
||||||
|
rarity_level: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
interface FishRarity {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
iucn_code: any;
|
||||||
|
cites_appendix: any;
|
||||||
|
vn_law: boolean;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// https://docs.expo.dev/guides/using-eslint/
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
const { defineConfig } = require('eslint/config');
|
import expoConfig from "eslint-config-expo/flat";
|
||||||
const expoConfig = require('eslint-config-expo/flat');
|
import { defineConfig } from "eslint/config";
|
||||||
|
|
||||||
module.exports = defineConfig([
|
export default defineConfig([
|
||||||
expoConfig,
|
expoConfig,
|
||||||
{
|
{
|
||||||
ignores: ['dist/*'],
|
ignores: ["dist/*"],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
163
hooks/use-app-theme.ts
Normal file
163
hooks/use-app-theme.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Custom hook for easy theme access throughout the app
|
||||||
|
* Provides styled components and theme utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { StyleSheet, TextStyle, ViewStyle } from "react-native";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
export function useAppTheme() {
|
||||||
|
const { colors, colorScheme, themeMode, setThemeMode, getColor } =
|
||||||
|
useThemeContext();
|
||||||
|
|
||||||
|
// Common styled components
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
// Container styles
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
surface: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: colors.text,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
// Button styles
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
secondaryButton: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
// Text styles
|
||||||
|
primaryButtonText: {
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
} as TextStyle,
|
||||||
|
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
} as TextStyle,
|
||||||
|
|
||||||
|
// Input styles
|
||||||
|
textInput: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
} as ViewStyle & TextStyle,
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
separator: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.separator,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
// Status styles
|
||||||
|
successContainer: {
|
||||||
|
backgroundColor: `${colors.success}20`,
|
||||||
|
borderColor: colors.success,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
warningContainer: {
|
||||||
|
backgroundColor: `${colors.warning}20`,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
errorContainer: {
|
||||||
|
backgroundColor: `${colors.error}20`,
|
||||||
|
borderColor: colors.error,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
} as ViewStyle,
|
||||||
|
}),
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme utilities
|
||||||
|
const utils = useMemo(
|
||||||
|
() => ({
|
||||||
|
// Get opacity color
|
||||||
|
getOpacityColor: (
|
||||||
|
colorName: keyof typeof colors,
|
||||||
|
opacity: number = 0.1
|
||||||
|
) => {
|
||||||
|
const color = colors[colorName];
|
||||||
|
const hex = color.replace("#", "");
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if current theme is dark
|
||||||
|
isDark: colorScheme === "dark",
|
||||||
|
|
||||||
|
// Check if current theme is light
|
||||||
|
isLight: colorScheme === "light",
|
||||||
|
|
||||||
|
// Toggle between light and dark (ignoring system)
|
||||||
|
toggleTheme: () => {
|
||||||
|
const newMode = colorScheme === "dark" ? "light" : "dark";
|
||||||
|
setThemeMode(newMode);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors, colorScheme, setThemeMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
colors,
|
||||||
|
styles,
|
||||||
|
utils,
|
||||||
|
colorScheme,
|
||||||
|
themeMode,
|
||||||
|
setThemeMode,
|
||||||
|
getColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppTheme = ReturnType<typeof useAppTheme>;
|
||||||
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;
|
||||||
|
};
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
* https://docs.expo.dev/guides/color-schemes/
|
* https://docs.expo.dev/guides/color-schemes/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
import { Colors } from "@/constants/theme";
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
|
|
||||||
export function useThemeColor(
|
export function useThemeColor(
|
||||||
props: { light?: string; dark?: string },
|
props: { light?: string; dark?: string },
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
) {
|
) {
|
||||||
const theme = useColorScheme() ?? 'light';
|
const theme = useColorScheme() ?? "light";
|
||||||
const colorFromProps = props[theme];
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
if (colorFromProps) {
|
if (colorFromProps) {
|
||||||
|
|||||||
193
hooks/use-theme-context.tsx
Normal file
193
hooks/use-theme-context.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Theme Context Hook for managing app-wide theme state.
|
||||||
|
* Supports Light, Dark, and System (automatic) modes across Expo platforms.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Requires expo-system-ui plugin in app.json for Android status bar support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ColorName, Colors } from "@/constants/theme";
|
||||||
|
import { getStorageItem, setStorageItem } from "@/utils/storage";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Appearance,
|
||||||
|
AppState,
|
||||||
|
AppStateStatus,
|
||||||
|
useColorScheme as useRNColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
colorScheme: ColorScheme;
|
||||||
|
colors: typeof Colors.light;
|
||||||
|
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||||
|
getColor: (colorName: ColorName) => string;
|
||||||
|
isHydrated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "theme_mode";
|
||||||
|
|
||||||
|
const getSystemScheme = (): ColorScheme => {
|
||||||
|
const scheme = Appearance.getColorScheme();
|
||||||
|
// console.log("[Theme] Appearance.getColorScheme():", scheme);
|
||||||
|
return scheme === "dark" ? "dark" : "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isThemeMode = (value: unknown): value is ThemeMode => {
|
||||||
|
return value === "light" || value === "dark" || value === "system";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [systemScheme, setSystemScheme] =
|
||||||
|
useState<ColorScheme>(getSystemScheme);
|
||||||
|
const [themeMode, setThemeModeState] = useState<ThemeMode>("system");
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
|
const syncSystemScheme = useCallback(() => {
|
||||||
|
const next = getSystemScheme();
|
||||||
|
// console.log("[Theme] syncSystemScheme computed:", next);
|
||||||
|
setSystemScheme((current) => (current === next ? current : next));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rnScheme = useRNColorScheme();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rnScheme) return;
|
||||||
|
const next = rnScheme === "dark" ? "dark" : "light";
|
||||||
|
// console.log("[Theme] useColorScheme hook emitted:", rnScheme);
|
||||||
|
setSystemScheme((current) => (current === next ? current : next));
|
||||||
|
}, [rnScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
|
||||||
|
const next = colorScheme === "dark" ? "dark" : "light";
|
||||||
|
// console.log("[Theme] Appearance listener fired with:", colorScheme);
|
||||||
|
setSystemScheme((current) => (current === next ? current : next));
|
||||||
|
});
|
||||||
|
|
||||||
|
syncSystemScheme();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [syncSystemScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] System scheme detected:", systemScheme);
|
||||||
|
}, [systemScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextState: AppStateStatus) => {
|
||||||
|
if (nextState === "active") {
|
||||||
|
// console.log("[Theme] AppState active → scheduling system scheme sync");
|
||||||
|
setTimeout(() => {
|
||||||
|
// console.log("[Theme] AppState sync callback running");
|
||||||
|
syncSystemScheme();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [syncSystemScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const hydrateThemeMode = async () => {
|
||||||
|
try {
|
||||||
|
const savedThemeMode = await getStorageItem(THEME_STORAGE_KEY);
|
||||||
|
if (isMounted && isThemeMode(savedThemeMode)) {
|
||||||
|
setThemeModeState(savedThemeMode);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Theme] Failed to load theme mode:", error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsHydrated(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hydrateThemeMode();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorScheme: ColorScheme =
|
||||||
|
themeMode === "system" ? systemScheme : themeMode;
|
||||||
|
|
||||||
|
const colors = useMemo(() => Colors[colorScheme], [colorScheme]);
|
||||||
|
|
||||||
|
const setThemeMode = useCallback(async (mode: ThemeMode) => {
|
||||||
|
setThemeModeState(mode);
|
||||||
|
try {
|
||||||
|
await setStorageItem(THEME_STORAGE_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Theme] Failed to save theme mode:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] window defined:", typeof window !== "undefined");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getColor = useCallback(
|
||||||
|
(colorName: ColorName) => colors[colorName] ?? colors.text,
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] Mode:", themeMode);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] Derived colorScheme:", colorScheme);
|
||||||
|
}, [colorScheme]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
themeMode,
|
||||||
|
colorScheme,
|
||||||
|
colors,
|
||||||
|
setThemeMode,
|
||||||
|
getColor,
|
||||||
|
isHydrated,
|
||||||
|
}),
|
||||||
|
[themeMode, colorScheme, colors, setThemeMode, getColor, isHydrated]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextType {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeContext = useTheme;
|
||||||
|
|
||||||
|
export function useColorScheme(): ColorScheme {
|
||||||
|
return useTheme().colorScheme;
|
||||||
|
}
|
||||||
201
locales/en.json
Normal file
201
locales/en.json
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"theme": "Theme",
|
||||||
|
"theme_light": "Light",
|
||||||
|
"theme_dark": "Dark",
|
||||||
|
"theme_system": "System"
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
202
locales/vi.json
Normal file
202
locales/vi.json
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"theme": "Giao diện",
|
||||||
|
"theme_light": "Sáng",
|
||||||
|
"theme_dark": "Tối",
|
||||||
|
"theme_system": "Hệ thống"
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
const { withNativeWind } = require("nativewind/metro");
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
module.exports = withNativeWind(config, { input: "./global.css" });
|
module.exports = withNativeWind(config, { input: './global.css' });
|
||||||
|
|||||||
2482
package-lock.json
generated
2482
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -5,42 +5,58 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/html-elements": "^0.10.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@islacel/react-native-custom-switch": "^1.0.10",
|
||||||
|
"@legendapp/motion": "^2.5.3",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
"expo": "~54.0.20",
|
"expo": "~54.0.20",
|
||||||
|
"expo-camera": "~17.0.9",
|
||||||
"expo-constants": "~18.0.10",
|
"expo-constants": "~18.0.10",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"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-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.66.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
"react-native-maps": "^1.20.1",
|
"react-native-maps": "^1.20.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.0",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "^5.6.2",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "^15.14.0",
|
||||||
"react-native-toast-message": "^2.3.3",
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "^0.5.2",
|
||||||
|
"react-stately": "^3.42.0",
|
||||||
|
"tailwind-variants": "^0.1.20",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -48,7 +64,7 @@
|
|||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|||||||
172
services/device_events.ts
Normal file
172
services/device_events.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
AUTO_REFRESH_INTERVAL,
|
||||||
|
EVENT_ALARM_DATA,
|
||||||
|
EVENT_BANZONE_DATA,
|
||||||
|
EVENT_ENTITY_DATA,
|
||||||
|
EVENT_GPS_DATA,
|
||||||
|
EVENT_TRACK_POINTS_DATA,
|
||||||
|
} from "@/constants";
|
||||||
|
import {
|
||||||
|
queryAlarm,
|
||||||
|
queryEntities,
|
||||||
|
queryGpsData,
|
||||||
|
queryTrackPoints,
|
||||||
|
} from "@/controller/DeviceController";
|
||||||
|
import { queryBanzones } from "@/controller/MapController";
|
||||||
|
import eventBus from "@/utils/eventBus";
|
||||||
|
|
||||||
|
const intervals: {
|
||||||
|
gps: ReturnType<typeof setInterval> | null;
|
||||||
|
alarm: ReturnType<typeof setInterval> | null;
|
||||||
|
entities: ReturnType<typeof setInterval> | null;
|
||||||
|
trackPoints: ReturnType<typeof setInterval> | null;
|
||||||
|
banzones: ReturnType<typeof setInterval> | null;
|
||||||
|
} = {
|
||||||
|
gps: null,
|
||||||
|
alarm: null,
|
||||||
|
entities: null,
|
||||||
|
trackPoints: null,
|
||||||
|
banzones: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getGpsEventBus() {
|
||||||
|
if (intervals.gps) return;
|
||||||
|
// console.log("Starting GPS poller");
|
||||||
|
|
||||||
|
const getGpsData = async () => {
|
||||||
|
try {
|
||||||
|
// console.log("GPS: fetching data...");
|
||||||
|
const resp = await queryGpsData();
|
||||||
|
if (resp && resp.data) {
|
||||||
|
// console.log("GPS: emitting data", resp.data);
|
||||||
|
eventBus.emit(EVENT_GPS_DATA, resp.data);
|
||||||
|
} else {
|
||||||
|
console.log("GPS: no data returned");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("GPS: fetch error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run immediately once, then schedule
|
||||||
|
getGpsData();
|
||||||
|
intervals.gps = setInterval(() => {
|
||||||
|
getGpsData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlarmEventBus() {
|
||||||
|
if (intervals.alarm) return;
|
||||||
|
// console.log("Goi ham get Alarm");
|
||||||
|
const getAlarmData = async () => {
|
||||||
|
try {
|
||||||
|
// console.log("Alarm: fetching data...");
|
||||||
|
const resp = await queryAlarm();
|
||||||
|
if (resp && resp.data) {
|
||||||
|
// console.log(
|
||||||
|
// "Alarm: emitting data",
|
||||||
|
// resp.data?.alarms?.length ?? resp.data
|
||||||
|
// );
|
||||||
|
eventBus.emit(EVENT_ALARM_DATA, resp.data);
|
||||||
|
} else {
|
||||||
|
console.log("Alarm: no data returned");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Alarm: fetch error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getAlarmData();
|
||||||
|
intervals.alarm = setInterval(() => {
|
||||||
|
getAlarmData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntitiesEventBus() {
|
||||||
|
if (intervals.entities) return;
|
||||||
|
// console.log("Goi ham get Entities");
|
||||||
|
const getEntitiesData = async () => {
|
||||||
|
try {
|
||||||
|
// console.log("Entities: fetching data...");
|
||||||
|
const resp = await queryEntities();
|
||||||
|
if (resp && resp.length > 0) {
|
||||||
|
// console.log("Entities: emitting", resp.length);
|
||||||
|
eventBus.emit(EVENT_ENTITY_DATA, resp);
|
||||||
|
} else {
|
||||||
|
console.log("Entities: no data returned");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Entities: fetch error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getEntitiesData();
|
||||||
|
intervals.entities = setInterval(() => {
|
||||||
|
getEntitiesData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrackPointsEventBus() {
|
||||||
|
if (intervals.trackPoints) return;
|
||||||
|
// console.log("Goi ham get Track Points");
|
||||||
|
const getTrackPointsData = async () => {
|
||||||
|
try {
|
||||||
|
// console.log("TrackPoints: fetching data...");
|
||||||
|
const resp = await queryTrackPoints();
|
||||||
|
if (resp && resp.data && resp.data.length > 0) {
|
||||||
|
// console.log("TrackPoints: emitting", resp.data.length);
|
||||||
|
eventBus.emit(EVENT_TRACK_POINTS_DATA, resp.data);
|
||||||
|
} else {
|
||||||
|
console.log("TrackPoints: no data returned");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("TrackPoints: fetch error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getTrackPointsData();
|
||||||
|
intervals.trackPoints = setInterval(() => {
|
||||||
|
getTrackPointsData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBanzonesEventBus() {
|
||||||
|
if (intervals.banzones) return;
|
||||||
|
const getBanzonesData = async () => {
|
||||||
|
try {
|
||||||
|
// console.log("Banzones: fetching data...");
|
||||||
|
const resp = await queryBanzones();
|
||||||
|
if (resp && resp.data && resp.data.length > 0) {
|
||||||
|
// console.log("Banzones: emitting", resp.data.length);
|
||||||
|
eventBus.emit(EVENT_BANZONE_DATA, resp.data);
|
||||||
|
} else {
|
||||||
|
console.log("Banzones: no data returned");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Banzones: fetch error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getBanzonesData();
|
||||||
|
intervals.banzones = setInterval(() => {
|
||||||
|
getBanzonesData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopEvents() {
|
||||||
|
Object.keys(intervals).forEach((k) => {
|
||||||
|
const key = k as keyof typeof intervals;
|
||||||
|
if (intervals[key]) {
|
||||||
|
clearInterval(intervals[key] as ReturnType<typeof setInterval>);
|
||||||
|
intervals[key] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startEvents() {
|
||||||
|
getGpsEventBus();
|
||||||
|
getAlarmEventBus();
|
||||||
|
getEntitiesEventBus();
|
||||||
|
getTrackPointsEventBus();
|
||||||
|
getBanzonesEventBus();
|
||||||
|
}
|
||||||
29
services/toast_service.tsx
Normal file
29
services/toast_service.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Toast from "react-native-toast-message";
|
||||||
|
|
||||||
|
export function showInfoToast(message: string) {
|
||||||
|
Toast.show({
|
||||||
|
type: "info",
|
||||||
|
text1: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSuccessToast(message: string) {
|
||||||
|
Toast.show({
|
||||||
|
type: "success",
|
||||||
|
text1: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showErrorToast(message: string) {
|
||||||
|
Toast.show({
|
||||||
|
type: "error",
|
||||||
|
text1: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showWarningToast(message: string) {
|
||||||
|
Toast.show({
|
||||||
|
type: "warn",
|
||||||
|
text1: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
26
state/use-fish.ts
Normal file
26
state/use-fish.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { queryFish } from "@/controller/FishController";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type Fish = {
|
||||||
|
fishSpecies: Model.FishSpeciesResponse[] | null;
|
||||||
|
getFishSpecies: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFishes = create<Fish>((set) => ({
|
||||||
|
fishSpecies: null,
|
||||||
|
getFishSpecies: async () => {
|
||||||
|
try {
|
||||||
|
const response = await queryFish();
|
||||||
|
console.log("Fish fetching API: ", response.data.length);
|
||||||
|
|
||||||
|
set({ fishSpecies: response.data, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when fetch fish: ", error);
|
||||||
|
set({ error: "Failed to fetch fish data", loading: false });
|
||||||
|
set({ fishSpecies: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
26
state/use-trip.ts
Normal file
26
state/use-trip.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { queryTrip } from "@/controller/TripController";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type Trip = {
|
||||||
|
trip: Model.Trip | null;
|
||||||
|
getTrip: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTrip = create<Trip>((set) => ({
|
||||||
|
trip: null,
|
||||||
|
getTrip: async () => {
|
||||||
|
try {
|
||||||
|
const response = await queryTrip();
|
||||||
|
console.log("Trip fetching API");
|
||||||
|
|
||||||
|
set({ trip: response.data, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when fetch trip: ", error);
|
||||||
|
set({ error: "Failed to fetch trip data", loading: false });
|
||||||
|
set({ trip: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
@@ -1,13 +1,206 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: process.env.DARK_MODE ? process.env.DARK_MODE : 'class',
|
||||||
content: [
|
content: [
|
||||||
"./app/**/*.{js,jsx,ts,tsx}",
|
'./app/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||||
"./components/**/*.{js,jsx,ts,tsx}",
|
'./components/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||||
"./screens/**/*.{js,jsx,ts,tsx}",
|
'./utils/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||||
|
'./*.{html,js,jsx,ts,tsx,mdx}',
|
||||||
|
'./src/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||||
|
],
|
||||||
|
presets: [require('nativewind/preset')],
|
||||||
|
important: 'html',
|
||||||
|
safelist: [
|
||||||
|
{
|
||||||
|
pattern:
|
||||||
|
/(bg|border|text|stroke|fill)-(primary|secondary|tertiary|error|success|warning|info|typography|outline|background|indicator)-(0|50|100|200|300|400|500|600|700|800|900|950|white|gray|black|error|warning|muted|success|info|light|dark|primary)/,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
presets: [require("nativewind/preset")],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
0: 'rgb(var(--color-primary-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-primary-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-primary-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-primary-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-primary-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-primary-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-primary-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-primary-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-primary-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-primary-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-primary-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-primary-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
0: 'rgb(var(--color-secondary-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-secondary-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-secondary-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-secondary-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-secondary-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-secondary-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-secondary-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-secondary-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-secondary-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-secondary-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-secondary-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-secondary-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
tertiary: {
|
||||||
|
50: 'rgb(var(--color-tertiary-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-tertiary-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-tertiary-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-tertiary-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-tertiary-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-tertiary-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-tertiary-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-tertiary-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-tertiary-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-tertiary-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-tertiary-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
0: 'rgb(var(--color-error-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-error-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-error-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-error-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-error-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-error-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-error-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-error-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-error-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-error-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-error-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-error-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
0: 'rgb(var(--color-success-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-success-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-success-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-success-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-success-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-success-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-success-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-success-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-success-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-success-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-success-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-success-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
0: 'rgb(var(--color-warning-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-warning-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-warning-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-warning-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-warning-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-warning-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-warning-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-warning-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-warning-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-warning-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-warning-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-warning-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
0: 'rgb(var(--color-info-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-info-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-info-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-info-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-info-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-info-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-info-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-info-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-info-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-info-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-info-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-info-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
0: 'rgb(var(--color-typography-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-typography-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-typography-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-typography-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-typography-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-typography-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-typography-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-typography-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-typography-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-typography-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-typography-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-typography-950)/<alpha-value>)',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
gray: '#D4D4D4',
|
||||||
|
black: '#181718',
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
0: 'rgb(var(--color-outline-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-outline-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-outline-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-outline-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-outline-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-outline-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-outline-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-outline-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-outline-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-outline-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-outline-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-outline-950)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
0: 'rgb(var(--color-background-0)/<alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-background-50)/<alpha-value>)',
|
||||||
|
100: 'rgb(var(--color-background-100)/<alpha-value>)',
|
||||||
|
200: 'rgb(var(--color-background-200)/<alpha-value>)',
|
||||||
|
300: 'rgb(var(--color-background-300)/<alpha-value>)',
|
||||||
|
400: 'rgb(var(--color-background-400)/<alpha-value>)',
|
||||||
|
500: 'rgb(var(--color-background-500)/<alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-background-600)/<alpha-value>)',
|
||||||
|
700: 'rgb(var(--color-background-700)/<alpha-value>)',
|
||||||
|
800: 'rgb(var(--color-background-800)/<alpha-value>)',
|
||||||
|
900: 'rgb(var(--color-background-900)/<alpha-value>)',
|
||||||
|
950: 'rgb(var(--color-background-950)/<alpha-value>)',
|
||||||
|
error: 'rgb(var(--color-background-error)/<alpha-value>)',
|
||||||
|
warning: 'rgb(var(--color-background-warning)/<alpha-value>)',
|
||||||
|
muted: 'rgb(var(--color-background-muted)/<alpha-value>)',
|
||||||
|
success: 'rgb(var(--color-background-success)/<alpha-value>)',
|
||||||
|
info: 'rgb(var(--color-background-info)/<alpha-value>)',
|
||||||
|
light: '#FBFBFB',
|
||||||
|
dark: '#181719',
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
primary: 'rgb(var(--color-indicator-primary)/<alpha-value>)',
|
||||||
|
info: 'rgb(var(--color-indicator-info)/<alpha-value>)',
|
||||||
|
error: 'rgb(var(--color-indicator-error)/<alpha-value>)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
heading: undefined,
|
||||||
|
body: undefined,
|
||||||
|
mono: undefined,
|
||||||
|
jakarta: ['var(--font-plus-jakarta-sans)'],
|
||||||
|
roboto: ['var(--font-roboto)'],
|
||||||
|
code: ['var(--font-source-code-pro)'],
|
||||||
|
inter: ['var(--font-inter)'],
|
||||||
|
'space-mono': ['var(--font-space-mono)'],
|
||||||
|
},
|
||||||
|
fontWeight: {
|
||||||
|
extrablack: '950',
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
'2xs': '10px',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'hard-1': '-2px 2px 8px 0px rgba(38, 38, 38, 0.20)',
|
||||||
|
'hard-2': '0px 3px 10px 0px rgba(38, 38, 38, 0.20)',
|
||||||
|
'hard-3': '2px 2px 8px 0px rgba(38, 38, 38, 0.20)',
|
||||||
|
'hard-4': '0px -3px 10px 0px rgba(38, 38, 38, 0.20)',
|
||||||
|
'hard-5': '0px 2px 10px 0px rgba(38, 38, 38, 0.10)',
|
||||||
|
'soft-1': '0px 0px 10px rgba(38, 38, 38, 0.1)',
|
||||||
|
'soft-2': '0px 0px 20px rgba(38, 38, 38, 0.2)',
|
||||||
|
'soft-3': '0px 0px 30px rgba(38, 38, 38, 0.1)',
|
||||||
|
'soft-4': '0px 0px 40px rgba(38, 38, 38, 0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./*"
|
"./*"
|
||||||
|
],
|
||||||
|
"tailwind.config": [
|
||||||
|
"./tailwind.config.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
5
utils/eventBus.ts
Normal file
5
utils/eventBus.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import EventEmitter from "eventemitter3";
|
||||||
|
|
||||||
|
const eventBus = new EventEmitter();
|
||||||
|
|
||||||
|
export default eventBus;
|
||||||
@@ -86,3 +86,25 @@ export const getBanzoneNameByType = (type: number) => {
|
|||||||
return "Chưa có";
|
return "Chưa có";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const convertToDMS = (value: number, isLat: boolean): string => {
|
||||||
|
const deg = Math.floor(Math.abs(value));
|
||||||
|
const minFloat = (Math.abs(value) - deg) * 60;
|
||||||
|
const min = Math.floor(minFloat);
|
||||||
|
const sec = (minFloat - min) * 60;
|
||||||
|
|
||||||
|
const direction = value >= 0 ? (isLat ? "N" : "E") : isLat ? "S" : "W";
|
||||||
|
|
||||||
|
return `${deg}°${min}'${sec.toFixed(2)}"${direction}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chuyển đổi tốc độ từ km/h sang knot (hải lý/giờ)
|
||||||
|
* @param kmh - tốc độ tính bằng km/h
|
||||||
|
* @returns tốc độ tính bằng knot
|
||||||
|
*/
|
||||||
|
export function kmhToKnot(kmh: number): number {
|
||||||
|
const KNOT_PER_KMH = 1 / 1.852; // 1 knot = 1.852 km/h
|
||||||
|
return parseFloat((kmh * KNOT_PER_KMH).toFixed(2)); // làm tròn 2 chữ số thập phân
|
||||||
|
}
|
||||||
|
|||||||
91
utils/sosUtils.ts
Normal file
91
utils/sosUtils.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Định nghĩa cấu trúc cho mỗi lý do cần hỗ trợ/SOS
|
||||||
|
*/
|
||||||
|
interface SosMessage {
|
||||||
|
ma: number; // Mã số thứ tự của lý do
|
||||||
|
moTa: string; // Mô tả ngắn gọn về sự cố
|
||||||
|
mucDoNghiemTrong: string;
|
||||||
|
chiTiet: string; // Chi tiết sự cố
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mảng 10 lý do phát tín hiệu SOS/Yêu cầu trợ giúp trên biển
|
||||||
|
* Sắp xếp từ nhẹ (yêu cầu hỗ trợ) đến nặng (SOS khẩn cấp)
|
||||||
|
*/
|
||||||
|
export const sosMessage: SosMessage[] = [
|
||||||
|
{
|
||||||
|
ma: 11,
|
||||||
|
moTa: "Tình huống khẩn cấp, không kịp chọn !!!",
|
||||||
|
mucDoNghiemTrong: "Nguy Hiem Can Ke (SOS)",
|
||||||
|
chiTiet:
|
||||||
|
"Tình huống nghiêm trọng nhất, đe dọa trực tiếp đến tính mạng tất cả người trên tàu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 1,
|
||||||
|
moTa: "Hỏng hóc động cơ không tự khắc phục được",
|
||||||
|
mucDoNghiemTrong: "Nhe",
|
||||||
|
chiTiet: "Tàu bị trôi hoặc mắc cạn nhẹ; cần tàu lai hoặc thợ máy.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 2,
|
||||||
|
moTa: "Thiếu nhiên liệu/thực phẩm/nước uống nghiêm trọng",
|
||||||
|
mucDoNghiemTrong: "Nhe",
|
||||||
|
chiTiet:
|
||||||
|
"Dự trữ thiết yếu cạn kiệt do hành trình kéo dài không lường trước được.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 3,
|
||||||
|
moTa: "Sự cố y tế không nguy hiểm đến tính mạng",
|
||||||
|
mucDoNghiemTrong: "Trung Binh",
|
||||||
|
chiTiet:
|
||||||
|
"Cần chăm sóc y tế chuyên nghiệp khẩn cấp (ví dụ: gãy xương, viêm ruột thừa).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 4,
|
||||||
|
moTa: "Hỏng hóc thiết bị định vị/thông tin liên lạc chính",
|
||||||
|
mucDoNghiemTrong: "Trung Binh",
|
||||||
|
chiTiet: "Mất khả năng xác định vị trí hoặc liên lạc, tăng rủi ro bị lạc.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 5,
|
||||||
|
moTa: "Thời tiết cực đoan sắp tới không kịp trú ẩn",
|
||||||
|
mucDoNghiemTrong: "Trung Binh",
|
||||||
|
chiTiet:
|
||||||
|
"Tàu không kịp chạy vào nơi trú ẩn an toàn trước cơn bão lớn hoặc gió giật mạnh.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 6,
|
||||||
|
moTa: "Va chạm gây hư hỏng cấu trúc",
|
||||||
|
mucDoNghiemTrong: "Nang",
|
||||||
|
chiTiet:
|
||||||
|
"Tàu bị hư hại một phần do va chạm, cần kiểm tra và hỗ trợ lai dắt khẩn cấp.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 7,
|
||||||
|
moTa: "Có cháy/hỏa hoạn trên tàu không kiểm soát được",
|
||||||
|
mucDoNghiemTrong: "Nang",
|
||||||
|
chiTiet:
|
||||||
|
"Lửa bùng phát vượt quá khả năng chữa cháy của tàu, nguy cơ cháy lan.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 8,
|
||||||
|
moTa: "Tàu bị thủng/nước vào không kiểm soát được",
|
||||||
|
mucDoNghiemTrong: "Rat Nang",
|
||||||
|
chiTiet:
|
||||||
|
"Nước tràn vào khoang quá nhanh, vượt quá khả năng bơm tát, đe dọa tàu chìm.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 9,
|
||||||
|
moTa: "Sự cố y tế nguy hiểm đến tính mạng (MEDEVAC)",
|
||||||
|
mucDoNghiemTrong: "Rat Nang",
|
||||||
|
chiTiet:
|
||||||
|
"Thương tích/bệnh tật nghiêm trọng, cần sơ tán y tế (MEDEVAC) ngay lập tức bằng trực thăng/tàu cứu hộ.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 10,
|
||||||
|
moTa: "Tàu bị chìm/lật úp hoàn toàn hoặc sắp xảy ra",
|
||||||
|
mucDoNghiemTrong: "Nguy Hiem Can Ke (SOS)",
|
||||||
|
chiTiet:
|
||||||
|
"Tình huống nghiêm trọng nhất, đe dọa trực tiếp đến tính mạng tất cả người trên tàu.",
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user