Compare commits
40 Commits
5801992eae
...
MinhNN
| Author | SHA1 | Date | |
|---|---|---|---|
| 554289ee1e | |||
| 6975358a7f | |||
| 51327c7d01 | |||
| 1d5b29e4a7 | |||
| 7cb35efd30 | |||
|
|
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 | |||
| 44fc6848a8 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,5 +3,6 @@
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
},
|
||||
"postman.settings.dotenv-detection-notification-visibility": false
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function login(body: Model.LoginRequestBody) {
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```typescript
|
||||
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null);
|
||||
const [gpsData, setGpsData] = useState<Model.GPSResponse | null>(null);
|
||||
```
|
||||
|
||||
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.
|
||||
- [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": {}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
502
THEME_GUIDE.md
Normal file
502
THEME_GUIDE.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# Theme System Documentation
|
||||
|
||||
## Tổng quan
|
||||
|
||||
Hệ thống theme hỗ trợ **Light Mode**, **Dark Mode** và **System Mode** (tự động theo hệ thống). Theme preference được lưu trong AsyncStorage và tự động khôi phục khi khởi động lại ứng dụng.
|
||||
|
||||
## Kiến trúc Theme System
|
||||
|
||||
### 1. Theme Provider (`hooks/use-theme-context.tsx`)
|
||||
|
||||
Theme Provider là core của hệ thống theme, quản lý state và đồng bộ với system theme.
|
||||
|
||||
**Các tính năng chính:**
|
||||
|
||||
- Quản lý `themeMode`: `'light' | 'dark' | 'system'`
|
||||
- Tự động detect system theme thông qua nhiều nguồn:
|
||||
- `Appearance.getColorScheme()` - iOS/Android system theme
|
||||
- `useColorScheme()` hook từ React Native
|
||||
- `Appearance.addChangeListener()` - listen system theme changes
|
||||
- `AppState` listener - sync lại khi app active
|
||||
- Lưu và restore theme preference từ AsyncStorage
|
||||
- Export ra `colorScheme` cuối cùng: `'light' | 'dark'`
|
||||
|
||||
**ThemeContextType:**
|
||||
|
||||
```typescript
|
||||
interface ThemeContextType {
|
||||
themeMode: ThemeMode; // User's choice: 'light' | 'dark' | 'system'
|
||||
colorScheme: ColorScheme; // Final theme: 'light' | 'dark'
|
||||
colors: typeof Colors.light; // Theme colors object
|
||||
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||
getColor: (colorName: ColorName) => string;
|
||||
isHydrated: boolean; // AsyncStorage đã load xong
|
||||
}
|
||||
```
|
||||
|
||||
**Cách hoạt động:**
|
||||
|
||||
```typescript
|
||||
// Xác định colorScheme cuối cùng
|
||||
const colorScheme: ColorScheme =
|
||||
themeMode === "system" ? systemScheme : themeMode;
|
||||
```
|
||||
|
||||
### 2. Colors Configuration (`constants/theme.ts`)
|
||||
|
||||
Định nghĩa tất cả colors cho light và dark theme:
|
||||
|
||||
```typescript
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: "#11181C",
|
||||
textSecondary: "#687076",
|
||||
background: "#fff",
|
||||
backgroundSecondary: "#f5f5f5",
|
||||
surface: "#ffffff",
|
||||
surfaceSecondary: "#f8f9fa",
|
||||
tint: "#0a7ea4",
|
||||
primary: "#007AFF",
|
||||
secondary: "#5AC8FA",
|
||||
success: "#34C759",
|
||||
warning: "#ff6600",
|
||||
error: "#FF3B30",
|
||||
icon: "#687076",
|
||||
border: "#C6C6C8",
|
||||
separator: "#E5E5E7",
|
||||
card: "#ffffff",
|
||||
// ... more colors
|
||||
},
|
||||
dark: {
|
||||
text: "#ECEDEE",
|
||||
textSecondary: "#8E8E93",
|
||||
background: "#000000",
|
||||
backgroundSecondary: "#1C1C1E",
|
||||
surface: "#1C1C1E",
|
||||
surfaceSecondary: "#2C2C2E",
|
||||
tint: "#fff",
|
||||
primary: "#0A84FF",
|
||||
secondary: "#64D2FF",
|
||||
success: "#30D158",
|
||||
warning: "#ff6600",
|
||||
error: "#FF453A",
|
||||
icon: "#8E8E93",
|
||||
border: "#38383A",
|
||||
separator: "#38383A",
|
||||
card: "#1C1C1E",
|
||||
// ... more colors
|
||||
},
|
||||
};
|
||||
|
||||
export type ColorName = keyof typeof Colors.light;
|
||||
```
|
||||
|
||||
### 3. Setup trong App (`app/_layout.tsx`)
|
||||
|
||||
Theme Provider phải wrap toàn bộ app:
|
||||
|
||||
```tsx
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AppThemeProvider>
|
||||
<AppContent />
|
||||
</AppThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { colorScheme } = useThemeContext();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack>{/* ... routes */}</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Cách sử dụng Theme
|
||||
|
||||
### 1. useThemeContext (Core Hook)
|
||||
|
||||
Hook chính để access theme state:
|
||||
|
||||
```tsx
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
function MyComponent() {
|
||||
const {
|
||||
themeMode, // 'light' | 'dark' | 'system'
|
||||
colorScheme, // 'light' | 'dark'
|
||||
colors, // Colors object
|
||||
setThemeMode, // Change theme
|
||||
getColor, // Get color by name
|
||||
isHydrated, // AsyncStorage loaded
|
||||
} = useThemeContext();
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.background }}>
|
||||
<Text style={{ color: colors.text }}>
|
||||
Mode: {themeMode}, Scheme: {colorScheme}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. useColorScheme Hook
|
||||
|
||||
Alias để lấy colorScheme nhanh:
|
||||
|
||||
```tsx
|
||||
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||
|
||||
function MyComponent() {
|
||||
const colorScheme = useColorScheme(); // 'light' | 'dark'
|
||||
|
||||
return <Text>Current theme: {colorScheme}</Text>;
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Lưu ý:** `useColorScheme` từ `use-theme-context.tsx`, KHÔNG phải từ `react-native`.
|
||||
|
||||
### 3. useThemeColor Hook
|
||||
|
||||
Override colors cho specific themes:
|
||||
|
||||
```tsx
|
||||
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||
|
||||
function MyComponent() {
|
||||
// Với override
|
||||
const backgroundColor = useThemeColor(
|
||||
{ light: "#ffffff", dark: "#1C1C1E" },
|
||||
"surface"
|
||||
);
|
||||
|
||||
// Không override, dùng color từ theme
|
||||
const textColor = useThemeColor({}, "text");
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor }}>
|
||||
<Text style={{ color: textColor }}>Text</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Cách hoạt động:**
|
||||
|
||||
```typescript
|
||||
// Ưu tiên props override trước, sau đó mới dùng Colors
|
||||
const colorFromProps = props[colorScheme];
|
||||
return colorFromProps || Colors[colorScheme][colorName];
|
||||
```
|
||||
|
||||
### 4. useAppTheme Hook (Recommended)
|
||||
|
||||
Hook tiện lợi với pre-built styles và utilities:
|
||||
|
||||
```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}>Primary Button</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={{ color: colors.text }}>
|
||||
Theme is {utils.isDark ? "Dark" : "Light"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: utils.getOpacityColor("primary", 0.1),
|
||||
}}
|
||||
>
|
||||
<Text>Transparent background</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Themed Components
|
||||
|
||||
**ThemedView** và **ThemedText** - Tự động apply theme colors:
|
||||
|
||||
```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="subtitle">Subtitle</ThemedText>
|
||||
<ThemedText type="default">Regular Text</ThemedText>
|
||||
<ThemedText type="link">Link Text</ThemedText>
|
||||
|
||||
{/* Override với custom colors */}
|
||||
<ThemedText lightColor="#000000" darkColor="#FFFFFF">
|
||||
Custom colored text
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**ThemedText types:**
|
||||
|
||||
- `default` - 16px regular
|
||||
- `title` - 32px bold
|
||||
- `subtitle` - 20px bold
|
||||
- `defaultSemiBold` - 16px semibold
|
||||
- `link` - 16px với color #0a7ea4
|
||||
|
||||
### 6. Theme Toggle Component
|
||||
|
||||
Component có sẵn để user chọn theme:
|
||||
|
||||
```tsx
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
function SettingsScreen() {
|
||||
return (
|
||||
<View>
|
||||
<ThemeToggle />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Component này hiển thị 3 options: Light, Dark, System với icons và labels đa ngôn ngữ.
|
||||
|
||||
## Available Styles từ useAppTheme
|
||||
|
||||
```typescript
|
||||
const { styles } = useAppTheme();
|
||||
|
||||
// Container styles
|
||||
styles.container; // Flex 1 container với background
|
||||
styles.surface; // Surface với padding 16, borderRadius 12
|
||||
styles.card; // Card với shadow, elevation
|
||||
|
||||
// Button styles
|
||||
styles.primaryButton; // Primary button với colors.primary
|
||||
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, padding
|
||||
|
||||
// Status styles
|
||||
styles.successContainer; // Success background với border
|
||||
styles.warningContainer; // Warning background với border
|
||||
styles.errorContainer; // Error background với border
|
||||
|
||||
// Utility
|
||||
styles.separator; // 1px line separator
|
||||
```
|
||||
|
||||
## Theme Utilities
|
||||
|
||||
```typescript
|
||||
const { utils } = useAppTheme();
|
||||
|
||||
// Check theme
|
||||
utils.isDark; // boolean - true nếu dark mode
|
||||
utils.isLight; // boolean - true nếu light mode
|
||||
|
||||
// Toggle theme (ignores system mode)
|
||||
utils.toggleTheme(); // Switch giữa light ↔ dark
|
||||
|
||||
// Get color với opacity
|
||||
utils.getOpacityColor("primary", 0.1); // rgba(0, 122, 255, 0.1)
|
||||
```
|
||||
|
||||
## Luồng hoạt động của Theme System
|
||||
|
||||
```
|
||||
1. App khởi động
|
||||
└─→ ThemeProvider mount
|
||||
├─→ Load saved themeMode từ AsyncStorage ('light'/'dark'/'system')
|
||||
├─→ Detect systemScheme từ OS
|
||||
│ ├─→ Appearance.getColorScheme()
|
||||
│ ├─→ useColorScheme() hook
|
||||
│ └─→ Appearance.addChangeListener()
|
||||
└─→ Tính toán colorScheme cuối cùng
|
||||
└─→ themeMode === 'system' ? systemScheme : themeMode
|
||||
|
||||
2. User thay đổi system theme
|
||||
└─→ Appearance listener fire
|
||||
└─→ Update systemScheme state
|
||||
└─→ Nếu themeMode === 'system'
|
||||
└─→ colorScheme tự động update
|
||||
└─→ Components re-render với colors mới
|
||||
|
||||
3. User chọn theme trong app
|
||||
└─→ setThemeMode('light'/'dark'/'system')
|
||||
├─→ Update themeMode state
|
||||
├─→ Save vào AsyncStorage
|
||||
└─→ colorScheme update
|
||||
└─→ Components re-render
|
||||
|
||||
4. App về foreground
|
||||
└─→ AppState listener fire
|
||||
└─→ Sync lại systemScheme (phòng user đổi system theme khi app background)
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
Theme preference được lưu với key: `'theme_mode'`
|
||||
|
||||
```typescript
|
||||
// Tự động xử lý bởi ThemeProvider
|
||||
await setStorageItem("theme_mode", "light" | "dark" | "system");
|
||||
const savedMode = await getStorageItem("theme_mode");
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Sử dụng hooks đúng context:**
|
||||
|
||||
- `useThemeContext()` - Khi cần full control (themeMode, setThemeMode)
|
||||
- `useColorScheme()` - Chỉ cần biết light/dark
|
||||
- `useAppTheme()` - Recommended cho UI components (có styles + utils)
|
||||
- `useThemeColor()` - Khi cần override colors
|
||||
|
||||
2. **Sử dụng Themed Components:**
|
||||
|
||||
```tsx
|
||||
// Good ✅
|
||||
<ThemedView>
|
||||
<ThemedText>Hello</ThemedText>
|
||||
</ThemedView>;
|
||||
|
||||
// Also good ✅
|
||||
const { colors } = useAppTheme();
|
||||
<View style={{ backgroundColor: colors.background }}>
|
||||
<Text style={{ color: colors.text }}>Hello</Text>
|
||||
</View>;
|
||||
```
|
||||
|
||||
3. **Tận dụng pre-built styles:**
|
||||
|
||||
```tsx
|
||||
// Good ✅
|
||||
const { styles } = useAppTheme();
|
||||
<TouchableOpacity style={styles.primaryButton}>
|
||||
|
||||
// Less good ❌
|
||||
<TouchableOpacity style={{
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12
|
||||
}}>
|
||||
```
|
||||
|
||||
4. **Sử dụng opacity colors:**
|
||||
|
||||
```tsx
|
||||
const { utils } = useAppTheme();
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: utils.getOpacityColor("primary", 0.1),
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
5. **Check theme correctly:**
|
||||
|
||||
```tsx
|
||||
// Good ✅
|
||||
const { utils } = useAppTheme();
|
||||
if (utils.isDark) { ... }
|
||||
|
||||
// Also good ✅
|
||||
const { colorScheme } = useThemeContext();
|
||||
if (colorScheme === 'dark') { ... }
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Theme không được lưu
|
||||
|
||||
- Kiểm tra AsyncStorage permissions
|
||||
- Check logs trong console: `[Theme] Failed to save theme mode:`
|
||||
|
||||
### Flash màu sắc khi khởi động
|
||||
|
||||
- ThemeProvider đã xử lý với `isHydrated` state
|
||||
- Chờ AsyncStorage load xong trước khi render
|
||||
|
||||
### System theme không update
|
||||
|
||||
- Check Appearance listener đã register: `[Theme] Registering appearance listener`
|
||||
- Check logs: `[Theme] System theme changed to: ...`
|
||||
- iOS: Restart app sau khi đổi system theme
|
||||
- Android: Cần `expo-system-ui` plugin trong `app.json`
|
||||
|
||||
### Colors không đúng
|
||||
|
||||
- Đảm bảo app wrapped trong `<AppThemeProvider>`
|
||||
- Check `colorScheme` trong console logs
|
||||
- Verify Colors object trong `constants/theme.ts`
|
||||
|
||||
## Migration Guide
|
||||
|
||||
Nếu đang dùng old theme system:
|
||||
|
||||
```tsx
|
||||
// Old ❌
|
||||
import { useColorScheme } from "react-native";
|
||||
const colorScheme = useColorScheme();
|
||||
const backgroundColor = colorScheme === "dark" ? "#000" : "#fff";
|
||||
|
||||
// New ✅
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
const { colors } = useAppTheme();
|
||||
const backgroundColor = colors.background;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Old ❌
|
||||
const [theme, setTheme] = useState("light");
|
||||
|
||||
// New ✅
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
const { themeMode, setThemeMode } = useThemeContext();
|
||||
```
|
||||
|
||||
## Debug Logs
|
||||
|
||||
Enable logs để debug theme issues:
|
||||
|
||||
```tsx
|
||||
// Trong use-theme-context.tsx, uncomment các dòng:
|
||||
console.log("[Theme] Appearance.getColorScheme():", scheme);
|
||||
console.log("[Theme] System theme changed to:", newScheme);
|
||||
console.log("[Theme] Mode:", themeMode);
|
||||
console.log("[Theme] Derived colorScheme:", colorScheme);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Trong use-theme-color.ts:
|
||||
console.log("Detected theme:", theme); // Đã có sẵn
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Trong _layout.tsx:
|
||||
console.log("Color Scheme: ", colorScheme); // Đã có sẵn
|
||||
```
|
||||
44
app.json
44
app.json
@@ -9,7 +9,14 @@
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"CFBundleLocalizations": [
|
||||
"en",
|
||||
"vi"
|
||||
]
|
||||
},
|
||||
"bundleIdentifier": "com.minhnn86.sgwapp"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
@@ -19,7 +26,12 @@
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
],
|
||||
"package": "com.minhnn86.sgwapp"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
@@ -28,6 +40,7 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-system-ui",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -39,11 +52,38 @@
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-localization",
|
||||
{
|
||||
"supportedLocales": {
|
||||
"ios": [
|
||||
"en",
|
||||
"vi"
|
||||
],
|
||||
"android": [
|
||||
"en",
|
||||
"vi"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": 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 { IconSymbol } from "@/components/ui/icon-symbol";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||
import { startEvents, stopEvents } from "@/services/device_events";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function TabLayout() {
|
||||
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 (
|
||||
<Tabs
|
||||
@@ -19,7 +41,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Giám sát",
|
||||
title: t("navigation.home"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="map.fill" color={color} />
|
||||
),
|
||||
@@ -29,7 +51,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="tripInfo"
|
||||
options={{
|
||||
title: "Chuyến Đi",
|
||||
title: t("navigation.trip"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="ferry.fill" color={color} />
|
||||
),
|
||||
@@ -38,7 +60,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="diary"
|
||||
options={{
|
||||
title: "Nhật Ký",
|
||||
title: t("navigation.diary"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="book.closed.fill" color={color} />
|
||||
),
|
||||
@@ -47,7 +69,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="sensor"
|
||||
options={{
|
||||
title: "Cảm biến",
|
||||
title: t("navigation.sensor"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol
|
||||
size={28}
|
||||
@@ -60,7 +82,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="setting"
|
||||
options={{
|
||||
title: "Cài đặt",
|
||||
title: t("navigation.setting"),
|
||||
tabBarIcon: ({ 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";
|
||||
|
||||
export default function Warning() {
|
||||
const [isShowModal, setIsShowModal] = useState(false);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -32,4 +31,17 @@ const styles = StyleSheet.create({
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#007AFF",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
marginTop: 20,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,181 +1,133 @@
|
||||
import GPSInfoPanel from "@/components/map/GPSInfoPanel";
|
||||
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
||||
import { PolygonWithLabel } from "@/components/map/PolygonWithLabel";
|
||||
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
||||
import { PolylineWithLabel } from "@/components/map/PolylineWithLabel";
|
||||
import { showToastError } from "@/config";
|
||||
import SosButton from "@/components/map/SosButton";
|
||||
import {
|
||||
AUTO_REFRESH_INTERVAL,
|
||||
ENTITY,
|
||||
EVENT_ALARM_DATA,
|
||||
EVENT_BANZONE_DATA,
|
||||
EVENT_ENTITY_DATA,
|
||||
EVENT_GPS_DATA,
|
||||
EVENT_TRACK_POINTS_DATA,
|
||||
IOS_PLATFORM,
|
||||
LIGHT_THEME,
|
||||
} from "@/constants";
|
||||
import {
|
||||
queryAlarm,
|
||||
queryEntities,
|
||||
queryGpsData,
|
||||
queryTrackPoints,
|
||||
} from "@/controller/DeviceController";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme.web";
|
||||
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 { useBanzones } from "@/state/use-banzones";
|
||||
import eventBus from "@/utils/eventBus";
|
||||
import {
|
||||
convertWKTLineStringToLatLngArray,
|
||||
convertWKTtoLatLngString,
|
||||
} from "@/utils/geom";
|
||||
import { Image as ExpoImage } from "expo-image";
|
||||
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 { 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() {
|
||||
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 [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>(
|
||||
null
|
||||
);
|
||||
const [entityData, setEntityData] = useState<
|
||||
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 [zoomLevel, setZoomLevel] = useState(10);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
||||
number[][] | null
|
||||
>(null);
|
||||
PolylineWithLabelProps[]
|
||||
>([]);
|
||||
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||
number[][][] | null
|
||||
>(null);
|
||||
const [, setZoneGeometries] = useState<Map<string, any>>(new Map());
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
PolygonWithLabelProps[]
|
||||
>([]);
|
||||
const platform = usePlatform();
|
||||
const theme = useColorScheme();
|
||||
const { banzones, getBanzone } = useBanzones();
|
||||
const banzonesRef = useRef(banzones);
|
||||
// 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);
|
||||
};
|
||||
const theme = useThemeContext().colorScheme;
|
||||
const scale = useRef(new Animated.Value(0)).current;
|
||||
const opacity = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
banzonesRef.current = banzones;
|
||||
}, [banzones]);
|
||||
|
||||
const areGeometriesEqual = (
|
||||
left?: {
|
||||
geom_type: number;
|
||||
geom_lines?: string | null;
|
||||
geom_poly?: string | null;
|
||||
},
|
||||
right?: {
|
||||
geom_type: number;
|
||||
geom_lines?: string | null;
|
||||
geom_poly?: string | null;
|
||||
getGpsEventBus();
|
||||
getAlarmEventBus();
|
||||
getEntitiesEventBus();
|
||||
getBanzonesEventBus();
|
||||
getTrackPointsEventBus();
|
||||
const queryGpsData = (gpsData: Model.GPSResponse) => {
|
||||
if (gpsData) {
|
||||
// console.log("GPS Data: ", gpsData);
|
||||
setGpsData(gpsData);
|
||||
} else {
|
||||
setGpsData(null);
|
||||
setPolygonCoordinates([]);
|
||||
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 queryAlarmData = (alarmData: Model.AlarmResponse) => {
|
||||
// console.log("Alarm Data: ", alarmData.alarms.length);
|
||||
setAlarmData(alarmData);
|
||||
};
|
||||
|
||||
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) =>
|
||||
coord[0] === nextPolygon[coordIndex][0] &&
|
||||
coord[1] === nextPolygon[coordIndex][1]
|
||||
);
|
||||
});
|
||||
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 () => {
|
||||
try {
|
||||
const response = await queryAlarm();
|
||||
// console.log("AlarmData: ", response.data);
|
||||
setAlarmData(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Alarm Data: ", error);
|
||||
showToastError("Lỗi", "Không thể lấy dữ liệu báo động");
|
||||
setBanzoneData(banzoneData);
|
||||
};
|
||||
const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
|
||||
// console.log("TrackPoints Data: ", TrackPointsData.length);
|
||||
if (TrackPointsData && TrackPointsData.length > 0) {
|
||||
setTrackPointsData(TrackPointsData);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
eventBus.on(EVENT_GPS_DATA, queryGpsData);
|
||||
// console.log("Registering event handlers in HomeScreen");
|
||||
eventBus.on(EVENT_GPS_DATA, queryGpsData);
|
||||
// console.log("Subscribed to EVENT_GPS_DATA");
|
||||
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");
|
||||
|
||||
const currentBanzones = banzonesRef.current || [];
|
||||
let nextPolyline: number[][] | null = null;
|
||||
let nextMultiPolygon: number[][][] | null = null;
|
||||
let foundPolyline = false;
|
||||
let foundPolygon = false;
|
||||
return () => {
|
||||
// console.log("Unregistering event handlers in HomeScreen");
|
||||
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");
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Process zones để tìm geometries
|
||||
for (const entity of entities) {
|
||||
useEffect(() => {
|
||||
setPolylineCoordinates([]);
|
||||
setPolygonCoordinates([]);
|
||||
if (!entityData) return;
|
||||
if (!banzoneData) return;
|
||||
for (const entity of entityData) {
|
||||
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
||||
continue;
|
||||
}
|
||||
@@ -187,183 +139,68 @@ export default function HomeScreen() {
|
||||
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());
|
||||
setPolylineCoordinates([]);
|
||||
setPolygonCoordinates([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let polylines: PolylineWithLabelProps[] = [];
|
||||
let polygons: PolygonWithLabelProps[] = [];
|
||||
|
||||
for (const zone of zones) {
|
||||
const geom = currentBanzones.find((b) => b.id === zone.zone_id);
|
||||
// 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) {
|
||||
foundPolyline = true;
|
||||
// if(oldEntityData.find(e => e.id === ))
|
||||
// foundPolyline = true;
|
||||
const coordinates = convertWKTLineStringToLatLngArray(
|
||||
geom_lines || ""
|
||||
);
|
||||
if (coordinates.length > 0) {
|
||||
nextPolyline = coordinates;
|
||||
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;
|
||||
// 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 });
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
setPolylineCoordinates(polylines);
|
||||
setPolygonCoordinates(polygons);
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getBanzone();
|
||||
}, [getBanzone]);
|
||||
}, [banzoneData, entityData]);
|
||||
|
||||
// Hàm tính radius cố định khi zoom change
|
||||
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 (
|
||||
<SafeAreaProvider style={styles.container}>
|
||||
{banzones.length > 0 && (
|
||||
<Text className="hidden">Banzones loaded: {banzones.length}</Text>
|
||||
)}
|
||||
<View
|
||||
// edges={["top"]}
|
||||
style={styles.container}
|
||||
>
|
||||
<MapView
|
||||
onMapReady={handleMapReady}
|
||||
onPoiClick={(point) => {
|
||||
console.log("Poi clicked: ", point.nativeEvent);
|
||||
}}
|
||||
onRegionChangeComplete={handleRegionChangeComplete}
|
||||
style={styles.map}
|
||||
// initialRegion={getMapRegion()}
|
||||
@@ -428,85 +303,109 @@ export default function HomeScreen() {
|
||||
showsBuildings={false}
|
||||
showsIndoors={false}
|
||||
loadingEnabled={true}
|
||||
mapType="standard"
|
||||
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
||||
rotateEnabled={false}
|
||||
>
|
||||
{trackPoints &&
|
||||
trackPoints.length > 0 &&
|
||||
trackPoints.map((point, index) => {
|
||||
{trackPointsData &&
|
||||
trackPointsData.length > 0 &&
|
||||
trackPointsData.map((point, index) => {
|
||||
// console.log(`Rendering circle ${index}:`, point);
|
||||
return (
|
||||
<Circle
|
||||
key={index}
|
||||
key={`circle-${index}`}
|
||||
center={{
|
||||
latitude: point.lat,
|
||||
longitude: point.lon,
|
||||
}}
|
||||
zIndex={50}
|
||||
// zIndex={50}
|
||||
// radius={platform === IOS_PLATFORM ? 200 : 50}
|
||||
radius={circleRadius}
|
||||
fillColor="rgba(16, 85, 201, 0.6)"
|
||||
strokeColor="rgba(16, 85, 201, 0.8)"
|
||||
strokeColor="rgba(16, 85, 201, 0.7)"
|
||||
fillColor="rgba(16, 85, 201, 0.7)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{polylineCoordinates && (
|
||||
{polylineCoordinates.length > 0 && (
|
||||
<>
|
||||
{polylineCoordinates.map((polyline, index) => (
|
||||
<PolylineWithLabel
|
||||
coordinates={polylineCoordinates.map((coord) => ({
|
||||
latitude: coord[0],
|
||||
longitude: coord[1],
|
||||
}))}
|
||||
label="Tuyến bờ"
|
||||
key={`polyline-${index}-${gpsData?.lat || 0}-${
|
||||
gpsData?.lon || 0
|
||||
}`}
|
||||
coordinates={polyline.coordinates}
|
||||
label={polyline.label}
|
||||
content={polyline.content}
|
||||
strokeColor="#FF5733"
|
||||
strokeWidth={4}
|
||||
showDistance={false}
|
||||
zIndex={50}
|
||||
// zIndex={50}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{polygonCoordinates && polygonCoordinates.length > 0 && (
|
||||
{polygonCoordinates.length > 0 && (
|
||||
<>
|
||||
{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 (
|
||||
<PolygonWithLabel
|
||||
key={polygonKey}
|
||||
coordinates={polygon.map((coords) => ({
|
||||
latitude: coords[0],
|
||||
longitude: coords[1],
|
||||
}))}
|
||||
label="Test khu đánh bắt"
|
||||
content="Thời gian cấm (từ tháng 1 đến tháng 12)"
|
||||
key={`polygon-${index}-${gpsData?.lat || 0}-${
|
||||
gpsData?.lon || 0
|
||||
}`}
|
||||
coordinates={polygon.coordinates}
|
||||
label={polygon.label}
|
||||
content={polygon.content}
|
||||
fillColor="rgba(16, 85, 201, 0.6)"
|
||||
strokeColor="rgba(16, 85, 201, 0.8)"
|
||||
strokeWidth={2}
|
||||
zIndex={50}
|
||||
// zIndex={50}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{gpsData && (
|
||||
{gpsData !== null && (
|
||||
<Marker
|
||||
key={
|
||||
platform === IOS_PLATFORM
|
||||
? `${gpsData.lat}-${gpsData.lon}`
|
||||
: "gps-data"
|
||||
}
|
||||
coordinate={{
|
||||
latitude: gpsData.lat,
|
||||
longitude: gpsData.lon,
|
||||
}}
|
||||
title={
|
||||
zIndex={20}
|
||||
anchor={
|
||||
platform === IOS_PLATFORM
|
||||
? "Tàu của mình - iOS"
|
||||
: "Tàu của mình - Android"
|
||||
? { x: 0.5, y: 0.5 }
|
||||
: { x: 0.6, y: 0.4 }
|
||||
}
|
||||
zIndex={100}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
tracksViewChanges={platform === IOS_PLATFORM ? true : undefined}
|
||||
>
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<ExpoImage
|
||||
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)}
|
||||
<View style={styles.pingContainer}>
|
||||
{alarmData?.level === 3 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pingCircle,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
opacity,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<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,
|
||||
@@ -522,13 +421,16 @@ export default function HomeScreen() {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Marker>
|
||||
)}
|
||||
</MapView>
|
||||
<TouchableOpacity style={styles.button} onPress={drawPolyline}>
|
||||
<Text style={styles.buttonText}>Get GPS Data</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaProvider>
|
||||
|
||||
<View className="absolute top-14 right-2 shadow-md">
|
||||
<SosButton />
|
||||
</View>
|
||||
<GPSInfoPanel gpsData={gpsData!} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -540,7 +442,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
button: {
|
||||
display: "none",
|
||||
// display: "none",
|
||||
position: "absolute",
|
||||
top: 50,
|
||||
right: 20,
|
||||
@@ -562,20 +464,25 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: "row",
|
||||
|
||||
pingContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
justifyContent: "center",
|
||||
overflow: "visible",
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
pingCircle: {
|
||||
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";
|
||||
|
||||
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 (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.container}>
|
||||
<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>
|
||||
</ScrollView>
|
||||
|
||||
<ScanQRCode
|
||||
visible={scanModalVisible}
|
||||
onClose={() => setScanModalVisible(false)}
|
||||
onScanned={handleQRCodeScanned}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -25,11 +82,48 @@ const styles = StyleSheet.create({
|
||||
fontSize: 32,
|
||||
fontWeight: "700",
|
||||
lineHeight: 40,
|
||||
marginBottom: 10,
|
||||
marginBottom: 30,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
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 { StyleSheet } from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
import EnIcon from "@/assets/icons/en_icon.png";
|
||||
import VnIcon from "@/assets/icons/vi_icon.png";
|
||||
import RotateSwitch from "@/components/rotate-switch";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { api } from "@/config";
|
||||
import { TOKEN } from "@/constants";
|
||||
import { DOMAIN, TOKEN } from "@/constants";
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { removeStorageItem } from "@/utils/storage";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
type Todo = {
|
||||
userId: number;
|
||||
id: number;
|
||||
@@ -18,54 +23,131 @@ type Todo = {
|
||||
export default function SettingScreen() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Todo | null>(null);
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const { colors } = useAppTheme();
|
||||
const [isEnabled, setIsEnabled] = useState(locale === "vi");
|
||||
|
||||
// useEffect(() => {
|
||||
// getData();
|
||||
// }, []);
|
||||
// Sync isEnabled state khi locale thay đổi
|
||||
useEffect(() => {
|
||||
setIsEnabled(locale === "vi");
|
||||
}, [locale]);
|
||||
|
||||
const getData = async () => {
|
||||
try {
|
||||
const response = await api.get("/todos/1");
|
||||
setData(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
}
|
||||
const toggleSwitch = async () => {
|
||||
const newLocale = isEnabled ? "en" : "vi";
|
||||
await setLocale(newLocale);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">Settings</ThemedText>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
{t("navigation.setting")}
|
||||
</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}
|
||||
onTouchEnd={() => {
|
||||
removeStorageItem(TOKEN);
|
||||
router.replace("/auth/login");
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onTouchEnd={async () => {
|
||||
await removeStorageItem(TOKEN);
|
||||
await removeStorageItem(DOMAIN);
|
||||
router.navigate("/auth/login");
|
||||
}}
|
||||
>
|
||||
<ThemedText type="defaultSemiBold">Đăng xuất</ThemedText>
|
||||
<ThemedText type="defaultSemiBold" style={styles.buttonText}>
|
||||
{t("auth.logout")}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{data && (
|
||||
<ThemedView style={{ marginTop: 20 }}>
|
||||
<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>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
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: {
|
||||
marginTop: 20,
|
||||
padding: 10,
|
||||
backgroundColor: "#007AFF",
|
||||
borderRadius: 5,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
},
|
||||
debugSection: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,14 +5,20 @@ import CrewListTable from "@/components/tripInfo/CrewListTable";
|
||||
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
|
||||
import NetListTable from "@/components/tripInfo/NetListTable";
|
||||
import TripCostTable from "@/components/tripInfo/TripCostTable";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function TripInfoScreen() {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.titleText}>Thông Tin Chuyến Đi</Text>
|
||||
<Text style={[styles.titleText, { color: colors.text }]}>
|
||||
{t("trip.infoTrip")}
|
||||
</Text>
|
||||
<View style={styles.buttonWrapper}>
|
||||
<ButtonCreateNewHaulOrTrip />
|
||||
</View>
|
||||
@@ -34,6 +40,10 @@ export default function TripInfoScreen() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
@@ -57,6 +67,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
marginTop: 15,
|
||||
marginBottom: 15,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 32,
|
||||
|
||||
@@ -7,14 +7,20 @@ import { Stack, useRouter } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
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 { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import "@/global.css";
|
||||
import { I18nProvider } from "@/hooks/use-i18n";
|
||||
import { ThemeProvider as AppThemeProvider, useThemeContext } from "@/hooks/use-theme-context";
|
||||
import Toast from "react-native-toast-message";
|
||||
import "../global.css";
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
function AppContent() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useThemeContext();
|
||||
console.log("Color Scheme: ", colorScheme);
|
||||
|
||||
useEffect(() => {
|
||||
setRouterInstance(router);
|
||||
@@ -48,7 +54,17 @@ export default function RootLayout() {
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
<Toast />
|
||||
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AppThemeProvider>
|
||||
<AppContent />
|
||||
</AppThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import EnIcon from "@/assets/icons/en_icon.png";
|
||||
import VnIcon from "@/assets/icons/vi_icon.png";
|
||||
import RotateSwitch from "@/components/rotate-switch";
|
||||
import ScanQRCode from "@/components/ScanQRCode";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { showToastError } from "@/config";
|
||||
import { TOKEN } from "@/constants";
|
||||
import SliceSwitch from "@/components/ui/slice-switch";
|
||||
import { DOMAIN, TOKEN } from "@/constants";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { login } from "@/controller/AuthController";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import {
|
||||
ColorScheme as ThemeColorScheme,
|
||||
useTheme,
|
||||
useThemeContext,
|
||||
} from "@/hooks/use-theme-context";
|
||||
import { showErrorToast, showWarningToast } from "@/services/toast_service";
|
||||
import {
|
||||
getStorageItem,
|
||||
removeStorageItem,
|
||||
setStorageItem,
|
||||
} from "@/utils/storage";
|
||||
import { parseJwtToken } from "@/utils/token";
|
||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
@@ -22,49 +36,101 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
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 { setThemeMode } = useThemeContext();
|
||||
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 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) {
|
||||
return;
|
||||
}
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseJwtToken(token);
|
||||
console.log("Parse Token: ", parsed);
|
||||
|
||||
const { exp } = parsed;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneHour = 60 * 60;
|
||||
if (exp - now < oneHour) {
|
||||
await removeStorageItem(TOKEN);
|
||||
await removeStorageItem(DOMAIN);
|
||||
} else {
|
||||
router.replace("/(tabs)");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVNLang(locale === "vi");
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
if (!username.trim() || !password.trim()) {
|
||||
showToastError("Lỗi", "Vui lòng nhập tài khoản và mật khẩu");
|
||||
if (!user?.trim() || !pass?.trim()) {
|
||||
showErrorToast("Vui lòng nhập tài khoản và mật khẩu");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const body: Model.LoginRequestBody = {
|
||||
username: username,
|
||||
password: password,
|
||||
username: user,
|
||||
password: pass,
|
||||
};
|
||||
|
||||
const response = await login(body);
|
||||
@@ -80,8 +146,7 @@ export default function LoginScreen() {
|
||||
router.replace("/(tabs)");
|
||||
}
|
||||
} catch (error) {
|
||||
showToastError(
|
||||
"Lỗi",
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Đăng nhập thất bại"
|
||||
);
|
||||
} finally {
|
||||
@@ -89,20 +154,35 @@ export default function LoginScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchLanguage = (isVN: boolean) => {
|
||||
if (isVN) {
|
||||
setLocale("vi");
|
||||
} else {
|
||||
setLocale("en");
|
||||
}
|
||||
setIsVNLang(isVN);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: colors.background }}
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
>
|
||||
<ThemedView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.headerContainer}>
|
||||
{/* Logo */}
|
||||
<Image
|
||||
source={require("@/assets/images/logo.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
SGW App
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>
|
||||
Đăng nhập để tiếp tục
|
||||
{t("common.app_name")}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
@@ -110,11 +190,11 @@ export default function LoginScreen() {
|
||||
<View style={styles.formContainer}>
|
||||
{/* Username Input */}
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText style={styles.label}>Tài khoản</ThemedText>
|
||||
<ThemedText style={styles.label}>{t("auth.username")}</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Nhập tài khoản"
|
||||
placeholderTextColor="#999"
|
||||
placeholder={t("auth.username_placeholder")}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
editable={!loading}
|
||||
@@ -124,53 +204,147 @@ export default function LoginScreen() {
|
||||
|
||||
{/* Password Input */}
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText style={styles.label}>Mật khẩu</ThemedText>
|
||||
<ThemedText style={styles.label}>{t("auth.password")}</ThemedText>
|
||||
<View className="relative">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Nhập mật khẩu"
|
||||
placeholderTextColor="#999"
|
||||
placeholder={t("auth.password_placeholder")}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
secureTextEntry={!showPassword}
|
||||
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>
|
||||
|
||||
{/* Login Button */}
|
||||
{/* Login Button (3/4) + QR Scan (1/4) */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.loginButton,
|
||||
loading && styles.loginButtonDisabled,
|
||||
{ flex: 5, marginRight: 12, marginTop: 0 },
|
||||
]}
|
||||
onPress={handleLogin}
|
||||
onPress={() => handleLogin()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
<ActivityIndicator color={buttonTextColor} size="small" />
|
||||
) : (
|
||||
<Text style={styles.loginButtonText}>Đăng nhập</Text>
|
||||
<Text
|
||||
style={[styles.loginButtonText, { color: buttonTextColor }]}
|
||||
>
|
||||
{t("auth.login")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Footer text */}
|
||||
<View style={styles.footerContainer}>
|
||||
<ThemedText style={styles.footerText}>
|
||||
Chưa có tài khoản?{" "}
|
||||
<Text style={styles.linkText}>Đăng ký ngay</Text>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
marginTop: 0,
|
||||
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}
|
||||
value={colorScheme === "light"}
|
||||
onChange={(val) => {
|
||||
setThemeMode(val ? "light" : "dark");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Copyright */}
|
||||
<View style={styles.copyrightContainer}>
|
||||
<ThemedText style={styles.copyrightText}>
|
||||
© {new Date().getFullYear()} - {t("common.footer_text")}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
<ScanQRCode
|
||||
visible={isShowingQRScanner}
|
||||
onClose={() => setIsShowingQRScanner(false)}
|
||||
onScanned={handleQRCodeScanned}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (colors: typeof Colors.light, scheme: ThemeColorScheme) =>
|
||||
StyleSheet.create({
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -181,8 +355,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
logo: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
},
|
||||
@@ -202,16 +381,16 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#f5f5f5",
|
||||
color: "#000",
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: "#007AFF",
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
@@ -221,7 +400,6 @@ const styles = StyleSheet.create({
|
||||
opacity: 0.6,
|
||||
},
|
||||
loginButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
@@ -233,7 +411,58 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
},
|
||||
linkText: {
|
||||
color: "#007AFF",
|
||||
color: colors.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
copyrightContainer: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
copyrightText: {
|
||||
fontSize: 12,
|
||||
opacity: 0.6,
|
||||
textAlign: "center",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
languageSwitcherContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginTop: 24,
|
||||
gap: 20,
|
||||
},
|
||||
languageSwitcherLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
opacity: 0.8,
|
||||
},
|
||||
languageButtonsContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
},
|
||||
languageButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 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 |
BIN
assets/images/logo.png
Normal file
BIN
assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/owner.png
Normal file
BIN
assets/images/owner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,9 +1,22 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
presets: [['babel-preset-expo'], '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 { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||
|
||||
@@ -7,16 +8,18 @@ interface ButtonCancelTripProps {
|
||||
}
|
||||
|
||||
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
||||
title = "Hủy chuyến đi",
|
||||
title,
|
||||
onPress,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const displayTitle = title || t("trip.buttonCancelTrip.title");
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.text}>{title}</Text>
|
||||
<Text style={styles.text}>{displayTitle}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 React, { useState } from "react";
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, StyleSheet, View } from "react-native";
|
||||
import IconButton from "./IconButton";
|
||||
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
|
||||
|
||||
interface StartButtonProps {
|
||||
title?: string;
|
||||
gpsData?: Model.GPSResponse;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
interface a {
|
||||
fishingLogs?: Model.FishingLogInfo[] | null;
|
||||
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
||||
title = "Bắt đầu mẻ lưới",
|
||||
gpsData,
|
||||
onPress,
|
||||
}) => {
|
||||
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 = () => {
|
||||
if (isStarted) {
|
||||
Alert.alert(
|
||||
"Kết thúc mẻ lưới",
|
||||
"Bạn có chắc chắn muốn kết thúc mẻ lưới này?",
|
||||
[
|
||||
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
|
||||
{
|
||||
text: "Hủy",
|
||||
text: t("trip.cancelButton"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Kết thúc",
|
||||
text: t("trip.endButton"),
|
||||
onPress: () => {
|
||||
setIsStarted(false);
|
||||
Alert.alert("Thành công", "Đã kết thúc mẻ lưới!");
|
||||
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
]);
|
||||
} else {
|
||||
Alert.alert("Bắt đầu mẻ lưới", "Bạn có muốn bắt đầu mẻ lưới mới?", [
|
||||
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
||||
{
|
||||
text: "Hủy",
|
||||
text: t("trip.cancelButton"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Bắt đầu",
|
||||
text: t("trip.startButton"),
|
||||
onPress: () => {
|
||||
setIsStarted(true);
|
||||
Alert.alert("Thành công", "Đã bắt đầu mẻ lưới mới!");
|
||||
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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 (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isStarted && styles.buttonActive]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.8}
|
||||
<View>
|
||||
{trip?.trip_status === 2 ? (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" />}
|
||||
type="primary"
|
||||
style={{ backgroundColor: "green", borderRadius: 10 }}
|
||||
onPress={async () => handleStartTrip(3)}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<AntDesign
|
||||
name={isStarted ? "close" : "plus"}
|
||||
size={18}
|
||||
color="#fff"
|
||||
style={styles.icon}
|
||||
{t("trip.startTrip")}
|
||||
</IconButton>
|
||||
) : checkHaulFinished() ? (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" color={"white"} />}
|
||||
type="primary"
|
||||
style={{ borderRadius: 10 }}
|
||||
onPress={() => setIsFinishHaulModalOpen(true)}
|
||||
>
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.text}>
|
||||
{isStarted ? "Kết thúc mẻ lưới" : title}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||
|
||||
@@ -6,17 +7,16 @@ interface ButtonEndTripProps {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({
|
||||
title = "Kết thúc",
|
||||
onPress,
|
||||
}) => {
|
||||
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
|
||||
const { t } = useI18n();
|
||||
const displayTitle = title || t("trip.buttonEndTrip.title");
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.text}>{title}</Text>
|
||||
<Text style={styles.text}>{displayTitle}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
301
components/Select.tsx
Normal file
301
components/Select.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
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];
|
||||
|
||||
// Theme colors from context (consistent with other components)
|
||||
const { colors } = useThemeContext();
|
||||
const selectBackgroundColor = disabled
|
||||
? colors.backgroundSecondary
|
||||
: colors.surface;
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
height: sz.height,
|
||||
paddingHorizontal: sz.paddingHorizontal,
|
||||
backgroundColor: selectBackgroundColor,
|
||||
borderColor: disabled ? colors.border : colors.primary,
|
||||
},
|
||||
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={colors.primary} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{
|
||||
fontSize: sz.fontSize,
|
||||
color: disabled
|
||||
? colors.textSecondary
|
||||
: selectedValue
|
||||
? colors.text
|
||||
: colors.textSecondary,
|
||||
},
|
||||
]}
|
||||
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={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<AntDesign
|
||||
name={isOpen ? "up" : "down"}
|
||||
size={14}
|
||||
color={colors.textSecondary}
|
||||
style={styles.arrow}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isOpen && (
|
||||
<View
|
||||
style={[
|
||||
styles.dropdown,
|
||||
{
|
||||
top: containerHeight,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.searchInput,
|
||||
{
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
},
|
||||
]}
|
||||
placeholder="Search..."
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<ScrollView style={[styles.list, listStyle]}>
|
||||
{filteredOptions.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.value}
|
||||
style={[
|
||||
styles.option,
|
||||
{
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
item.disabled && styles.optionDisabled,
|
||||
selectedValue === item.value && {
|
||||
backgroundColor: colors.primary + "20", // Add transparency to primary color
|
||||
},
|
||||
]}
|
||||
onPress={() => !item.disabled && handleSelect(item.value)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
{
|
||||
color: colors.text,
|
||||
},
|
||||
item.disabled && {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
selectedValue === item.value && {
|
||||
color: colors.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{selectedValue === item.value && (
|
||||
<AntDesign name="check" size={16} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
text: {
|
||||
// Color is set dynamically via theme
|
||||
},
|
||||
suffix: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
icon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
arrow: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
dropdown: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderWidth: 1,
|
||||
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,
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
margin: 8,
|
||||
},
|
||||
list: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
option: {
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
optionDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
// optionSelected is handled dynamically via inline styles
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
// optionTextDisabled and optionTextSelected are handled dynamically via inline styles
|
||||
});
|
||||
|
||||
export default Select;
|
||||
24
components/map/Description.tsx
Normal file
24
components/map/Description.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface DescriptionProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
export const Description = ({
|
||||
title = "",
|
||||
description = "",
|
||||
}: DescriptionProps) => {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View className="flex-row gap-2 ">
|
||||
<ThemedText
|
||||
style={{ color: colors.textSecondary, fontSize: 16 }}
|
||||
>
|
||||
{title}:
|
||||
</ThemedText>
|
||||
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
126
components/map/GPSInfoPanel.tsx
Normal file
126
components/map/GPSInfoPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
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();
|
||||
const { colors, styles } = useAppTheme();
|
||||
|
||||
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,
|
||||
borderRadius: 4,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<ButtonCreateNewHaulOrTrip gpsData={gpsData} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
transform: [{ translateY }],
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 0,
|
||||
},
|
||||
]}
|
||||
className="absolute bottom-0 gap-5 right-0 px-4 pt-12 pb-2 left-0 h-auto w-full rounded-t-xl 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 rounded-full p-1"
|
||||
style={{ backgroundColor: colors.card }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={isExpanded ? "close" : "close"}
|
||||
size={20}
|
||||
color={colors.icon}
|
||||
/>
|
||||
</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 rounded-full p-2 shadow-lg"
|
||||
style={{ backgroundColor: colors.card }}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={24} color={colors.icon} />
|
||||
</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 React, { memo } from "react";
|
||||
import React, { useRef } from "react";
|
||||
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 {
|
||||
coordinates: {
|
||||
@@ -20,7 +22,7 @@ export interface PolygonWithLabelProps {
|
||||
/**
|
||||
* Component render Polygon kèm Label/Text ở giữa
|
||||
*/
|
||||
const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
||||
export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
|
||||
coordinates,
|
||||
label,
|
||||
content,
|
||||
@@ -33,6 +35,8 @@ const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
||||
if (!coordinates || coordinates.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const platform = usePlatform();
|
||||
const markerRef = useRef<MapMarker>(null);
|
||||
|
||||
const centerPoint = getPolygonCenter(coordinates);
|
||||
|
||||
@@ -47,12 +51,10 @@ const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
||||
|
||||
const labelFontSize = calculateFontSize(12);
|
||||
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 minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
|
||||
console.log("Min Width Scale: ", minWidthScale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Polygon
|
||||
@@ -64,15 +66,17 @@ const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
|
||||
/>
|
||||
{label && (
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
coordinate={centerPoint}
|
||||
zIndex={200}
|
||||
tracksViewChanges={false}
|
||||
zIndex={50}
|
||||
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||
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.labelContainer,
|
||||
{
|
||||
paddingHorizontal: 5 * paddingScale,
|
||||
paddingVertical: 5 * paddingScale,
|
||||
@@ -142,24 +146,3 @@ const styles = StyleSheet.create({
|
||||
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 {
|
||||
calculateTotalDistance,
|
||||
getMiddlePointOfPolyline,
|
||||
} from "@/utils/polyline";
|
||||
import React, { memo } from "react";
|
||||
import React, { useRef } from "react";
|
||||
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 {
|
||||
coordinates: {
|
||||
@@ -12,6 +14,7 @@ export interface PolylineWithLabelProps {
|
||||
longitude: number;
|
||||
}[];
|
||||
label?: string;
|
||||
content?: string;
|
||||
strokeColor?: string;
|
||||
strokeWidth?: number;
|
||||
showDistance?: boolean;
|
||||
@@ -21,9 +24,10 @@ export interface PolylineWithLabelProps {
|
||||
/**
|
||||
* Component render Polyline kèm Label/Text ở giữa
|
||||
*/
|
||||
const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({
|
||||
export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
|
||||
coordinates,
|
||||
label,
|
||||
content,
|
||||
strokeColor = "#FF5733",
|
||||
strokeWidth = 4,
|
||||
showDistance = false,
|
||||
@@ -35,14 +39,14 @@ const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({
|
||||
|
||||
const middlePoint = getMiddlePointOfPolyline(coordinates);
|
||||
const distance = calculateTotalDistance(coordinates);
|
||||
|
||||
const platform = usePlatform();
|
||||
const markerRef = useRef<MapMarker>(null);
|
||||
let displayText = label || "";
|
||||
if (showDistance) {
|
||||
displayText += displayText
|
||||
? ` (${distance.toFixed(2)}km)`
|
||||
: `${distance.toFixed(2)}km`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Polyline
|
||||
@@ -53,10 +57,13 @@ const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({
|
||||
/>
|
||||
{displayText && (
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
coordinate={middlePoint}
|
||||
zIndex={zIndex + 10}
|
||||
tracksViewChanges={false}
|
||||
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||
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.labelContainer}>
|
||||
@@ -103,22 +110,3 @@ const styles = StyleSheet.create({
|
||||
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
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
249
components/map/SosButton.tsx
Normal file
249
components/map/SosButton.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
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";
|
||||
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||
|
||||
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();
|
||||
|
||||
// Theme colors
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
// Dynamic styles
|
||||
const styles = SosButtonStyles(textColor, borderColor, errorColor, backgroundColor);
|
||||
|
||||
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={textColor + '99'} // Add transparency
|
||||
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 SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
|
||||
formGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
color: textColor,
|
||||
},
|
||||
errorBorder: {
|
||||
borderColor: errorColor,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: borderColor,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
color: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
errorInput: {
|
||||
borderColor: errorColor,
|
||||
},
|
||||
errorText: {
|
||||
color: errorColor,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default SosButton;
|
||||
@@ -8,8 +8,8 @@ import Animated, {
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { useColorScheme } from '@/hooks/use-theme-context';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ParallaxScrollView({
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const colorScheme = useColorScheme();
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
|
||||
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,36 +1,30 @@
|
||||
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 styles from "./style/CrewListTable.styles";
|
||||
|
||||
// ---------------------------
|
||||
// 🧩 Interface
|
||||
// ---------------------------
|
||||
interface CrewMember {
|
||||
id: string;
|
||||
maDinhDanh: string;
|
||||
ten: string;
|
||||
chucVu: string;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// ⚓ Dữ liệu mẫu
|
||||
// ---------------------------
|
||||
const data: CrewMember[] = [
|
||||
{
|
||||
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ủ" },
|
||||
];
|
||||
import CrewDetailModal from "./modal/CrewDetailModal";
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { createTableStyles } from "./ThemedTable";
|
||||
|
||||
const CrewListTable: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
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 handleToggle = () => {
|
||||
@@ -43,6 +37,19 @@ const CrewListTable: React.FC = () => {
|
||||
setCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleCrewPress = (crewId: string) => {
|
||||
const crew = data.find((item) => item.Person.personal_id === crewId);
|
||||
if (crew) {
|
||||
setSelectedCrew(crew);
|
||||
setModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false);
|
||||
setSelectedCrew(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header toggle */}
|
||||
@@ -51,14 +58,14 @@ const CrewListTable: React.FC = () => {
|
||||
onPress={handleToggle}
|
||||
style={styles.headerRow}
|
||||
>
|
||||
<Text style={styles.title}>Danh sách thuyền viên</Text>
|
||||
<Text style={styles.title}>{t("trip.crewList.title")}</Text>
|
||||
{collapsed && (
|
||||
<Text style={styles.totalCollapsed}>{tongThanhVien}</Text>
|
||||
)}
|
||||
<IconSymbol
|
||||
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||
size={16}
|
||||
color="#000"
|
||||
color={colors.icon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -74,31 +81,37 @@ const CrewListTable: React.FC = () => {
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={[styles.row, styles.tableHeader]}>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
Mã định danh
|
||||
<View style={styles.cellWrapper}>
|
||||
<Text style={[styles.cell, styles.headerText]}>
|
||||
{t("trip.crewList.nameHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
||||
</View>
|
||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||
Chức vụ
|
||||
{t("trip.crewList.roleHeader")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
|
||||
<Text style={[styles.cell]}>{item.ten}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
|
||||
<View key={item.Person.personal_id} style={styles.row}>
|
||||
<TouchableOpacity
|
||||
style={styles.cellWrapper}
|
||||
onPress={() => handleCrewPress(item.Person.personal_id)}
|
||||
>
|
||||
<Text style={[styles.cell, styles.linkText]}>
|
||||
{item.Person.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.cell, styles.right]}>{item.role}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={[styles.row]}>
|
||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||
Tổng cộng
|
||||
<Text style={[styles.cell, styles.footerText]}>
|
||||
{t("trip.crewList.totalLabel")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||
<Text style={[styles.cell, styles.right]}></Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -106,33 +119,46 @@ const CrewListTable: React.FC = () => {
|
||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||
{/* Header */}
|
||||
<View style={[styles.row, styles.tableHeader]}>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
Mã định danh
|
||||
<View style={styles.cellWrapper}>
|
||||
<Text style={[styles.cell, styles.headerText]}>
|
||||
{t("trip.crewList.nameHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
||||
</View>
|
||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||
Chức vụ
|
||||
{t("trip.crewList.roleHeader")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
|
||||
<Text style={[styles.cell]}>{item.ten}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
|
||||
<View key={item.Person.personal_id} style={styles.row}>
|
||||
<TouchableOpacity
|
||||
style={styles.cellWrapper}
|
||||
onPress={() => handleCrewPress(item.Person.personal_id)}
|
||||
>
|
||||
<Text style={[styles.cell, styles.linkText]}>
|
||||
{item.Person.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.cell, styles.right]}>{item.role}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={[styles.row]}>
|
||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||
Tổng cộng
|
||||
<Text style={[styles.cell, styles.footerText]}>
|
||||
{t("trip.crewList.totalLabel")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||
<Text style={[styles.cell, styles.right]}></Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Modal chi tiết thuyền viên */}
|
||||
<CrewDetailModal
|
||||
visible={modalVisible}
|
||||
onClose={handleCloseModal}
|
||||
crewData={selectedCrew}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
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 styles from "./style/FishingToolsTable.styles";
|
||||
|
||||
// ---------------------------
|
||||
// 🧩 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 },
|
||||
];
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { createTableStyles } from "./ThemedTable";
|
||||
|
||||
const FishingToolsTable: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
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 toValue = collapsed ? contentHeight : 0;
|
||||
@@ -45,12 +38,12 @@ const FishingToolsTable: React.FC = () => {
|
||||
onPress={handleToggle}
|
||||
style={styles.headerRow}
|
||||
>
|
||||
<Text style={styles.title}>Danh sách ngư cụ</Text>
|
||||
<Text style={styles.title}>{t("trip.fishingTools.title")}</Text>
|
||||
{collapsed && <Text style={styles.totalCollapsed}>{tongSoLuong}</Text>}
|
||||
<IconSymbol
|
||||
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||
size={16}
|
||||
color="#000"
|
||||
color={colors.icon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -66,24 +59,26 @@ const FishingToolsTable: React.FC = () => {
|
||||
>
|
||||
{/* Table Header */}
|
||||
<View style={[styles.row, styles.tableHeader]}>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
{t("trip.fishingTools.nameHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||
Số lượng
|
||||
{t("trip.fishingTools.quantityHeader")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.ten}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>{item.soLuong}</Text>
|
||||
{data.map((item, index) => (
|
||||
<View key={index} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.name}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>{item.number}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={[styles.row]}>
|
||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||
Tổng cộng
|
||||
{t("trip.fishingTools.totalLabel")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
||||
{tongSoLuong}
|
||||
@@ -95,24 +90,26 @@ const FishingToolsTable: React.FC = () => {
|
||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||
{/* Table Header */}
|
||||
<View style={[styles.row, styles.tableHeader]}>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
{t("trip.fishingTools.nameHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||
Số lượng
|
||||
{t("trip.fishingTools.quantityHeader")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.ten}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>{item.soLuong}</Text>
|
||||
{data.map((item, index) => (
|
||||
<View key={index} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.name}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>{item.number}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={[styles.row]}>
|
||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||
Tổng cộng
|
||||
{t("trip.fishingTools.totalLabel")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
||||
{tongSoLuong}
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
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 styles from "./style/NetListTable.styles";
|
||||
|
||||
// ---------------------------
|
||||
// 🧩 Interface
|
||||
// ---------------------------
|
||||
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" },
|
||||
];
|
||||
import CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal";
|
||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { createTableStyles } from "./ThemedTable";
|
||||
|
||||
const NetListTable: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
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 toValue = collapsed ? contentHeight : 0;
|
||||
@@ -37,6 +36,14 @@ const NetListTable: React.FC = () => {
|
||||
setCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleStatusPress = (id: string) => {
|
||||
const net = trip?.fishing_logs?.find((item) => item.fishing_log_id === id);
|
||||
if (net) {
|
||||
setSelectedNet(net);
|
||||
setModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header toggle */}
|
||||
@@ -45,12 +52,16 @@ const NetListTable: React.FC = () => {
|
||||
onPress={handleToggle}
|
||||
style={styles.headerRow}
|
||||
>
|
||||
<Text style={styles.title}>Danh sách mẻ lưới</Text>
|
||||
{collapsed && <Text style={styles.totalCollapsed}>{tongSoMe}</Text>}
|
||||
<Text style={styles.title}>{t("trip.netList.title")}</Text>
|
||||
{collapsed && (
|
||||
<Text style={styles.totalCollapsed}>
|
||||
{trip?.fishing_logs?.length}
|
||||
</Text>
|
||||
)}
|
||||
<IconSymbol
|
||||
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||
size={16}
|
||||
color="#000"
|
||||
color={colors.icon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -59,27 +70,56 @@ const NetListTable: React.FC = () => {
|
||||
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
||||
onLayout={(event) => {
|
||||
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);
|
||||
// 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 */}
|
||||
<View style={[styles.row, styles.tableHeader]}>
|
||||
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
|
||||
<Text style={[styles.sttCell, styles.headerText]}>
|
||||
{t("trip.netList.sttHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>
|
||||
{t("trip.netList.statusHeader")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
{trip?.fishing_logs?.map((item, index) => (
|
||||
<View key={item.fishing_log_id} style={styles.row}>
|
||||
{/* 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 */}
|
||||
<View style={[styles.cell, styles.statusContainer]}>
|
||||
<View style={styles.statusDot} />
|
||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
||||
<View
|
||||
style={[
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -89,24 +129,67 @@ const NetListTable: React.FC = () => {
|
||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||
{/* Header */}
|
||||
<View style={[styles.row, styles.tableHeader]}>
|
||||
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
|
||||
<Text style={[styles.sttCell, styles.headerText]}>
|
||||
{t("trip.netList.sttHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>
|
||||
{t("trip.netList.statusHeader")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
{trip?.fishing_logs?.map((item, index) => (
|
||||
<View key={item.fishing_log_id} style={styles.row}>
|
||||
{/* 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 */}
|
||||
<View style={[styles.cell, styles.statusContainer]}>
|
||||
<View style={styles.statusDot} />
|
||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
||||
<View
|
||||
style={[
|
||||
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>
|
||||
</View>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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 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 styles from "./style/TripCostTable.styles";
|
||||
|
||||
// ---------------------------
|
||||
// 🧩 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
|
||||
// ---------------------------
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
const TripCostTable: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
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 toValue = collapsed ? contentHeight : 0;
|
||||
@@ -73,6 +34,14 @@ const TripCostTable: React.FC = () => {
|
||||
setCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleViewDetail = () => {
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
@@ -85,21 +54,16 @@ const TripCostTable: React.FC = () => {
|
||||
// marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={styles.title}>Chi phí chuyến đi</Text>
|
||||
<Text style={styles.title}>{t("trip.costTable.title")}</Text>
|
||||
{collapsed && (
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
{ color: "#ff6600", fontWeight: "bold", marginLeft: 8 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.totalCollapsed]}>
|
||||
{tongCong.toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
<IconSymbol
|
||||
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
|
||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||
size={15}
|
||||
color="#000000"
|
||||
color={colors.icon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -116,17 +80,19 @@ const TripCostTable: React.FC = () => {
|
||||
{/* Header */}
|
||||
<View style={[styles.row, styles.header]}>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
Loại
|
||||
{t("trip.costTable.typeHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>
|
||||
{t("trip.costTable.totalCostHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
|
||||
{data.map((item, index) => (
|
||||
<View key={index} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.type}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>
|
||||
{item.tongChiPhi.toLocaleString()}
|
||||
{item.total_cost.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -134,29 +100,43 @@ const TripCostTable: React.FC = () => {
|
||||
{/* Footer */}
|
||||
<View style={[styles.row]}>
|
||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||
Tổng cộng
|
||||
{t("trip.costTable.totalLabel")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.total]}>
|
||||
{tongCong.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* View Detail Button */}
|
||||
{data.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={styles.viewDetailButton}
|
||||
onPress={handleViewDetail}
|
||||
>
|
||||
<Text style={styles.viewDetailText}>
|
||||
{t("trip.costTable.viewDetail")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||
{/* Header */}
|
||||
<View style={[styles.row, styles.header]}>
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
Loại
|
||||
{t("trip.costTable.typeHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>
|
||||
{t("trip.costTable.totalCostHeader")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
|
||||
{data.map((item, index) => (
|
||||
<View key={index} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.type}</Text>
|
||||
<Text style={[styles.cell, styles.right]}>
|
||||
{item.tongChiPhi.toLocaleString()}
|
||||
{item.total_cost.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -164,13 +144,32 @@ const TripCostTable: React.FC = () => {
|
||||
{/* Footer */}
|
||||
<View style={[styles.row]}>
|
||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||
Tổng cộng
|
||||
{t("trip.costTable.totalLabel")}
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.total]}>
|
||||
{tongCong.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* View Detail Button */}
|
||||
{data.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={styles.viewDetailButton}
|
||||
onPress={handleViewDetail}
|
||||
>
|
||||
<Text style={styles.viewDetailText}>
|
||||
{t("trip.costTable.viewDetail")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Modal */}
|
||||
<TripCostDetailModal
|
||||
visible={modalVisible}
|
||||
onClose={handleCloseModal}
|
||||
data={data}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
587
components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
Normal file
587
components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
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 { useThemeContext } from "@/hooks/use-theme-context";
|
||||
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 "./components/InfoSection";
|
||||
import { createStyles } 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 { colors } = useThemeContext();
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
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: colors.error,
|
||||
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: colors.primary,
|
||||
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;
|
||||
105
components/tripInfo/modal/CrewDetailModal.tsx
Normal file
105
components/tripInfo/modal/CrewDetailModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import React from "react";
|
||||
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||
import { createStyles } 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();
|
||||
const { colors } = useThemeContext();
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
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;
|
||||
245
components/tripInfo/modal/TripCostDetailModal.tsx
Normal file
245
components/tripInfo/modal/TripCostDetailModal.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { createStyles } 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 { colors } = useThemeContext();
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
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;
|
||||
119
components/tripInfo/modal/components/InfoSection.tsx
Normal file
119
components/tripInfo/modal/components/InfoSection.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
interface InfoSectionProps {
|
||||
fishingLog?: Model.FishingLog;
|
||||
stt?: number;
|
||||
}
|
||||
|
||||
export const InfoSection: React.FC<InfoSectionProps> = ({
|
||||
fishingLog,
|
||||
stt,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
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()
|
||||
: "-",
|
||||
},
|
||||
];
|
||||
|
||||
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 === t("trip.infoSection.statusCompleted")
|
||||
? styles.statusBadgeCompleted
|
||||
: styles.statusBadgeInProgress,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.statusBadgeText}>{item.value}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.infoValue}>{item.value}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
infoCard: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusBadgeCompleted: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
statusBadgeInProgress: {
|
||||
backgroundColor: colors.warning,
|
||||
},
|
||||
statusBadgeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export const createStyles = (colors: typeof Colors.light) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
closeIconButton: {
|
||||
backgroundColor: colors.error,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.primary,
|
||||
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: colors.surfaceSecondary,
|
||||
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: colors.text,
|
||||
},
|
||||
fishCardSubtitle: {
|
||||
fontSize: 15,
|
||||
color: colors.warning,
|
||||
fontWeight: "500",
|
||||
},
|
||||
fieldGroup: {
|
||||
marginBottom: 14,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
},
|
||||
inputDisabled: {
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
color: colors.textSecondary,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.error,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
fontWeight: "500",
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
removeButton: {
|
||||
backgroundColor: colors.error,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
removeButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: colors.primary,
|
||||
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: colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.separator,
|
||||
},
|
||||
saveButtonLarge: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
saveButtonLargeText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
emptyStateText: {
|
||||
textAlign: "center",
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
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 { Colors } from "@/constants/theme";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export const createStyles = (colors: typeof Colors.light) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
closeIconButton: {
|
||||
backgroundColor: colors.error,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
marginBottom: 15,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: colors.card,
|
||||
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: colors.separator,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 6,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
},
|
||||
});
|
||||
293
components/tripInfo/modal/style/NetDetailModal.styles.ts
Normal file
293
components/tripInfo/modal/style/NetDetailModal.styles.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export const createStyles = (colors: typeof Colors.light) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
closeIconButton: {
|
||||
backgroundColor: colors.error,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
marginBottom: 15,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: colors.card,
|
||||
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: colors.separator,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 6,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
statusBadgeCompleted: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
statusBadgeInProgress: {
|
||||
backgroundColor: colors.warning,
|
||||
},
|
||||
statusBadgeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
},
|
||||
statusBadgeTextCompleted: {
|
||||
color: "#fff",
|
||||
},
|
||||
statusBadgeTextInProgress: {
|
||||
color: "#fff",
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
editButton: {
|
||||
padding: 4,
|
||||
},
|
||||
editIconButton: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: colors.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.primary,
|
||||
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: colors.text,
|
||||
},
|
||||
totalCatchText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: colors.primary,
|
||||
},
|
||||
fishCard: {
|
||||
position: "relative",
|
||||
backgroundColor: colors.card,
|
||||
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: colors.textSecondary,
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
selectButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
selectButtonText: {
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
},
|
||||
optionsList: {
|
||||
position: "absolute",
|
||||
top: 46,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
backgroundColor: colors.surface,
|
||||
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: colors.separator,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
},
|
||||
optionsStatusFishList: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
backgroundColor: colors.surface,
|
||||
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: colors.primary,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
backgroundColor: colors.surface,
|
||||
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: colors.text,
|
||||
},
|
||||
fishCardSubtitle: {
|
||||
fontSize: 15,
|
||||
color: colors.warning,
|
||||
marginTop: 0,
|
||||
},
|
||||
addFishButton: {
|
||||
backgroundColor: colors.primary,
|
||||
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",
|
||||
},
|
||||
});
|
||||
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 { Colors } from "@/constants/theme";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export const createStyles = (colors: typeof Colors.light) =>
|
||||
StyleSheet.create({
|
||||
closeIconButton: {
|
||||
backgroundColor: colors.error,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
editButton: {
|
||||
padding: 4,
|
||||
},
|
||||
editIconButton: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: colors.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
itemCard: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
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: colors.textSecondary,
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
inputDisabled: {
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
totalContainer: {
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
totalText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
color: colors.warning,
|
||||
},
|
||||
footerTotal: {
|
||||
backgroundColor: colors.card,
|
||||
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: colors.primary,
|
||||
},
|
||||
footerAmount: {
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
color: colors.warning,
|
||||
},
|
||||
});
|
||||
@@ -1,67 +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",
|
||||
},
|
||||
left: {
|
||||
textAlign: "center",
|
||||
},
|
||||
right: {
|
||||
textAlign: "center",
|
||||
},
|
||||
headerText: {
|
||||
fontWeight: "600",
|
||||
},
|
||||
footerText: {
|
||||
color: "#007bff",
|
||||
fontWeight: "600",
|
||||
},
|
||||
footerTotal: {
|
||||
color: "#ff6600",
|
||||
fontWeight: "800",
|
||||
},
|
||||
});
|
||||
@@ -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,77 +0,0 @@
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default StyleSheet.create({
|
||||
container: {
|
||||
width: "100%",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "#eee",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.05,
|
||||
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.6,
|
||||
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: "left",
|
||||
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: "#111",
|
||||
},
|
||||
});
|
||||
@@ -1,61 +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",
|
||||
},
|
||||
});
|
||||
|
||||
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>;
|
||||
@@ -5,11 +5,11 @@ import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useColorScheme } from '@/hooks/use-theme-context';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const theme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
|
||||
@@ -23,11 +23,14 @@ const MAPPING = {
|
||||
"chevron.right": "chevron-right",
|
||||
"ferry.fill": "directions-boat",
|
||||
"map.fill": "map",
|
||||
"arrowshape.down.fill": "arrow-drop-down",
|
||||
"arrowshape.up.fill": "arrow-drop-up",
|
||||
"chevron.down": "arrow-drop-down",
|
||||
"chevron.up": "arrow-drop-up",
|
||||
"exclamationmark.triangle.fill": "warning",
|
||||
"book.closed.fill": "book",
|
||||
"dot.radiowaves.left.and.right": "sensors",
|
||||
xmark: "close",
|
||||
pencil: "edit",
|
||||
trash: "delete",
|
||||
} 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;
|
||||
262
components/ui/slice-switch.tsx
Normal file
262
components/ui/slice-switch.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect, 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 SliceSwitchProps = {
|
||||
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;
|
||||
value?: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
value,
|
||||
}: SliceSwitchProps) => {
|
||||
const { width: containerWidth, height: containerHeight } =
|
||||
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||
const [isOn, setIsOn] = useState(value ?? false);
|
||||
const [bgOn, setBgOn] = useState(value ?? false);
|
||||
const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
|
||||
const pressScale = useRef(new Animated.Value(1)).current;
|
||||
const overlayTranslateX = useRef(
|
||||
new Animated.Value(value ? containerWidth / 2 : 0)
|
||||
).current;
|
||||
const listenerIdRef = useRef<string | number | null>(null);
|
||||
|
||||
// Sync with external value prop if provided
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== isOn) {
|
||||
animateToValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const animateToValue = (next: boolean) => {
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
// 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 handleToggle = () => {
|
||||
const next = !isOn;
|
||||
if (value === undefined) {
|
||||
animateToValue(next);
|
||||
}
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
let routerInstance: Router | null = null;
|
||||
@@ -14,7 +16,13 @@ export const setRouterInstance = (router: Router) => {
|
||||
*/
|
||||
export const handle401 = () => {
|
||||
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 {
|
||||
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 axios, { AxiosInstance } from "axios";
|
||||
import { showToastError } from "./toast";
|
||||
import { handle401 } from "./auth";
|
||||
|
||||
const codeMessage = {
|
||||
200: "The server successfully returned the requested data。",
|
||||
@@ -37,9 +38,15 @@ api.interceptors.request.use(
|
||||
async (config) => {
|
||||
// Thêm auth token nếu có
|
||||
const token = await getStorageItem(TOKEN);
|
||||
const domain = await getStorageItem(DOMAIN);
|
||||
if (domain) {
|
||||
config.baseURL = `http://${domain}`;
|
||||
}
|
||||
if (token) {
|
||||
config.headers.Authorization = `${token}`;
|
||||
}
|
||||
// console.log("Domain Request: ", config.baseURL);
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -56,7 +63,9 @@ api.interceptors.response.use(
|
||||
if (!error.response) {
|
||||
const networkErrorMsg =
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -69,10 +78,9 @@ api.interceptors.response.use(
|
||||
statusText ||
|
||||
"Unknown error";
|
||||
|
||||
showToastError(`Lỗi ${status}`, errMsg);
|
||||
|
||||
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
||||
if (status === 401) {
|
||||
// handle401();
|
||||
handle401();
|
||||
}
|
||||
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 BASE_URL = "https://sgw-device.gms.vn";
|
||||
export const DOMAIN = "domain";
|
||||
export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
|
||||
export const MAP_POLYLINE_BAN = "ban-polyline";
|
||||
export const MAP_POLYGON_BAN = "ban-polygon";
|
||||
@@ -15,6 +15,12 @@ export const DARK_THEME = "dark";
|
||||
export const ROUTE_LOGIN = "/login";
|
||||
export const ROUTE_HOME = "/map";
|
||||
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
|
||||
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_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
||||
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.
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
const tintColorDark = '#fff';
|
||||
const tintColorLight = "#0a7ea4";
|
||||
const tintColorDark = "#fff";
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
text: "#11181C",
|
||||
textSecondary: "#687076",
|
||||
background: "#fff",
|
||||
backgroundSecondary: "#f5f5f5",
|
||||
surface: "#ffffff",
|
||||
surfaceSecondary: "#f8f9fa",
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
primary: "#007AFF",
|
||||
secondary: "#5AC8FA",
|
||||
success: "#34C759",
|
||||
warning: "#ff6600",
|
||||
error: "#FF3B30",
|
||||
icon: "#687076",
|
||||
iconSecondary: "#8E8E93",
|
||||
border: "#C6C6C8",
|
||||
separator: "#E5E5E7",
|
||||
tabIconDefault: "#687076",
|
||||
tabIconSelected: tintColorLight,
|
||||
card: "#ffffff",
|
||||
notification: "#FF3B30",
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
background: '#151718',
|
||||
text: "#ECEDEE",
|
||||
textSecondary: "#8E8E93",
|
||||
background: "#000000",
|
||||
backgroundSecondary: "#1C1C1E",
|
||||
surface: "#1C1C1E",
|
||||
surfaceSecondary: "#2C2C2E",
|
||||
tint: tintColorDark,
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
primary: "#0A84FF",
|
||||
secondary: "#64D2FF",
|
||||
success: "#30D158",
|
||||
warning: "#ff6600",
|
||||
error: "#FF453A",
|
||||
icon: "#8E8E93",
|
||||
iconSecondary: "#636366",
|
||||
border: "#38383A",
|
||||
separator: "#38383A",
|
||||
tabIconDefault: "#8E8E93",
|
||||
tabIconSelected: tintColorDark,
|
||||
card: "#1C1C1E",
|
||||
notification: "#FF453A",
|
||||
},
|
||||
};
|
||||
|
||||
export type ColorName = keyof typeof Colors.light;
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: 'system-ui',
|
||||
sans: "system-ui",
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: 'ui-serif',
|
||||
serif: "ui-serif",
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: 'ui-rounded',
|
||||
rounded: "ui-rounded",
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: 'ui-monospace',
|
||||
mono: "ui-monospace",
|
||||
},
|
||||
default: {
|
||||
sans: 'normal',
|
||||
serif: 'serif',
|
||||
rounded: 'normal',
|
||||
mono: 'monospace',
|
||||
sans: "normal",
|
||||
serif: "serif",
|
||||
rounded: "normal",
|
||||
mono: "monospace",
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
API_GET_GPS,
|
||||
API_PATH_ENTITIES,
|
||||
API_PATH_SHIP_TRACK_POINTS,
|
||||
API_SOS,
|
||||
} from "@/constants";
|
||||
import { transformEntityResponse } from "@/utils/tranform";
|
||||
|
||||
export async function queryGpsData() {
|
||||
return api.get<Model.GPSResonse>(API_GET_GPS);
|
||||
return api.get<Model.GPSResponse>(API_GET_GPS);
|
||||
}
|
||||
|
||||
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);
|
||||
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 DeviceController from "./DeviceController";
|
||||
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;
|
||||
}
|
||||
|
||||
interface GPSResonse {
|
||||
interface GPSResponse {
|
||||
lat: number;
|
||||
lon: number;
|
||||
s: number;
|
||||
@@ -80,4 +80,133 @@ declare namespace Model {
|
||||
geom_point?: string;
|
||||
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/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
import expoConfig from "eslint-config-expo/flat";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
module.exports = defineConfig([
|
||||
export default defineConfig([
|
||||
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 { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { useMemo } from "react";
|
||||
import { StyleSheet, TextStyle, ViewStyle } from "react-native";
|
||||
|
||||
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/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const theme = useColorScheme();
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
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;
|
||||
}
|
||||
202
locales/en.json
Normal file
202
locales/en.json
Normal file
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"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": {
|
||||
"infoTrip": "Trip Information",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
203
locales/vi.json
Normal file
203
locales/vi.json
Normal file
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"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": {
|
||||
"infoTrip": "Thông Tin Chuyến Đi",
|
||||
"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 { withNativeWind } = require("nativewind/metro");
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
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": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/html-elements": "^0.10.1",
|
||||
"@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-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"axios": "^1.13.1",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "~54.0.20",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"i18n-js": "^4.5.1",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||
"react-native-maps": "^1.20.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -48,7 +64,7 @@
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"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} */
|
||||
module.exports = {
|
||||
darkMode: process.env.DARK_MODE ? process.env.DARK_MODE : 'class',
|
||||
content: [
|
||||
"./app/**/*.{js,jsx,ts,tsx}",
|
||||
"./components/**/*.{js,jsx,ts,tsx}",
|
||||
"./screens/**/*.{js,jsx,ts,tsx}",
|
||||
'./app/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||
'./components/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||
'./utils/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||
'./*.{html,js,jsx,ts,tsx,mdx}',
|
||||
'./src/**/*.{html,js,jsx,ts,tsx,mdx}',
|
||||
],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {},
|
||||
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)/,
|
||||
},
|
||||
],
|
||||
theme: {
|
||||
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": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"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ó";
|
||||
}
|
||||
};
|
||||
|
||||
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