Compare commits

...

41 Commits

Author SHA1 Message Date
554289ee1e fix themes modal, Add English to the trip information tab 2025-11-21 18:46:51 +07:00
6975358a7f update darkMode for modal 2025-11-21 08:50:07 +07:00
51327c7d01 clear file not use 2025-11-20 16:54:37 +07:00
1d5b29e4a7 update doc themes 2025-11-19 19:18:39 +07:00
7cb35efd30 fix bug themes 2025-11-19 17:11:10 +07:00
Tran Anh Tuan
d8874fbe60 uncomment 2025-11-19 15:05:59 +07:00
00fd53bbd4 fix config theme system 2025-11-19 14:52:12 +07:00
Tran Anh Tuan
742d8f6bcc remove gluestackk-ui 2025-11-19 14:23:17 +07:00
f3cf10e5e6 fix theme system 2025-11-17 19:55:53 +07:00
862c4e42a4 update theme dark, light mode 2025-11-17 17:01:42 +07:00
Tran Anh Tuan
e725819c01 add en/vi language 2025-11-15 16:58:07 +07:00
Tran Anh Tuan
1a534eccb0 thêm quét QR đăng nhập, sửa lại logic để gọi api bằng ip thiết bị 2025-11-10 16:12:52 +07:00
c26de5aefc update netListTable, CreateOrUpdateHaulModal 2025-11-10 16:11:02 +07:00
Tran Anh Tuan
f3b0e7b7eb cập nhật api thêm/sửa mẻ 2025-11-10 10:45:31 +07:00
45746a6a0f update select dropDown 2025-11-10 00:08:33 +07:00
fd80f63bbe update CreateOrHaulModal.tsx 2025-11-09 18:34:58 +07:00
c19cc7e00a update style CreateOrUpdateHaulModal 2025-11-08 01:08:29 +07:00
Tran Anh Tuan
4d821646cf cập nhật modal add/edit fishingLog và sửa lỗi event ở map 2025-11-07 18:54:44 +07:00
c02b61163d Update from master 2025-11-07 17:56:13 +07:00
Tran Anh Tuan
53bf2d18e6 thêm modal thêm mới/cập nhật NKĐT 2025-11-07 16:50:47 +07:00
Tran Anh Tuan
f7b05f1e08 tadd select component 2025-11-07 15:28:55 +07:00
25b9e831d1 update netDetail 2025-11-07 11:54:16 +07:00
b9cd637b33 Update from MinhNN 2025-11-06 23:59:56 +07:00
b97e4e1097 fill data API NetDetail 2025-11-06 23:38:53 +07:00
04ca091f49 fix open table 2025-11-06 17:59:40 +07:00
Tran Anh Tuan
1b748285c9 sửa l fix xcall api getTrip 2025-11-06 17:45:03 +07:00
Tran Anh Tuan
aabd1109b2 thêm toast, thêm logic cho phần ButtonCreateNewHaulOrTrip 2025-11-06 17:30:04 +07:00
1ef83c9b22 fill data API CrewList, FishingTools, TripCost 2025-11-06 17:28:10 +07:00
6288e79622 call API trip 2025-11-05 23:47:54 +07:00
Tran Anh Tuan
62b18e5bc0 sửa lỗi hiển thị polyline, polygon ở map, thêm component ScanQrCode 2025-11-05 16:23:47 +07:00
Tran Anh Tuan
300271fce7 sửa sửa lỗi polygon không bị xóa trên iOS 2025-11-04 16:48:01 +07:00
Tran Anh Tuan
2137925ba9 thêm chức năng Sos và thêm glustack-ui 2025-11-04 16:24:54 +07:00
e535aaa1e8 update NetDetailModal 2025-11-04 14:18:30 +07:00
f3ad6e02f2 update NetDetailModal 2025-11-03 19:49:42 +07:00
Tran Anh Tuan
efe9749a8e sửa lỗi polygon không bị xóa ở map 2025-11-03 10:33:30 +07:00
67a80c1498 update netDetailModal 2025-11-02 23:39:17 +07:00
5cc760f818 update crewDetailModal 2025-11-02 13:42:50 +07:00
52d2f0f78b update login, modal detail in tripInfo 2025-11-01 19:47:45 +07:00
eea1482a88 Update from MinhNN 2025-11-01 00:59:04 +07:00
44fc6848a8 update login, detail table in tripInfo 2025-10-31 23:55:39 +07:00
Tran Anh Tuan
5801992eae thêm zustand để cấu hình global state, hook để lấy platform, thêm polyline và polygon b vào map 2025-10-31 19:54:16 +07:00
99 changed files with 12487 additions and 1055 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -3,5 +3,6 @@
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
},
"postman.settings.dotenv-detection-notification-visibility": false
}

View File

@@ -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
View File

@@ -0,0 +1,224 @@
# Localization Setup Guide
## Overview
Ứng dụng đã được cấu hình hỗ trợ 2 ngôn ngữ: **English (en)****Tiếng Việt (vi)** sử dụng `expo-localization``i18n-js`.
## Cấu trúc thư mục
```
/config
/localization
- i18n.ts # Cấu hình i18n (khởi tạo locale, enable fallback, etc.)
- localization.ts # Export main exports
/locales
- en.json # Các string tiếng Anh
- vi.json # Các string tiếng Việt
/hooks
- use-i18n.ts # Hook để sử dụng i18n trong components
/state
- use-locale-store.ts # Zustand store cho global locale state management
```
## Cách sử dụng
### 1. Import i18n hook trong component
```tsx
import { useI18n } from "@/hooks/use-i18n";
export default function MyComponent() {
const { t, locale, setLocale } = useI18n();
return (
<View>
<Text>{t("common.ok")}</Text>
<Text>{t("navigation.home")}</Text>
<Text>Current locale: {locale}</Text>
</View>
);
}
```
### 2. Thêm translation keys
#### Mở file `/locales/en.json` và thêm:
```json
{
"myFeature": {
"title": "My Feature Title",
"description": "My Feature Description"
}
}
```
#### Mở file `/locales/vi.json` và thêm translation tương ứng:
```json
{
"myFeature": {
"title": "Tiêu đề Tính năng Của Tôi",
"description": "Mô tả Tính năng Của Tôi"
}
}
```
### 3. Sử dụng trong component
```tsx
const { t } = useI18n();
return <Text>{t("myFeature.title")}</Text>;
```
## Cách thay đổi ngôn ngữ
```tsx
const { setLocale } = useI18n();
// Thay đổi sang Tiếng Việt
<Button onPress={() => setLocale('vi')} title="Tiếng Việt" />
// Thay đổi sang English
<Button onPress={() => setLocale('en')} title="English" />
```
**Lưu ý:** Ngôn ngữ được chọn sẽ được **lưu tự động vào storage**. Khi người dùng tắt app và mở lại, app sẽ sử dụng ngôn ngữ được chọn trước đó.
## Persistence (Lưu trữ tùy chọn ngôn ngữ) - Zustand Global State
Localization sử dụng **Zustand** cho global state management. Ngôn ngữ được chọn tự động được lưu vào `AsyncStorage` với key `app_locale_preference`.
**Quy trình:**
1. Khi app khởi động, `useLocaleStore.getState().initLocale()` được gọi trong `app/_layout.tsx`
2. Store sẽ load locale từ storage nếu có
3. Nếu không có, store sẽ detect ngôn ngữ thiết bị (`getLocales()`)
4. Khi người dùng gọi `setLocale('vi')`, nó sẽ:
- Cập nhật Zustand store ngay lập tức
- **Tự động lưu vào storage** để dùng lần tiếp theo
- Tất cả components lắng nghe sẽ re-render ngay lập tức
**Kết quả:** Khi bạn toggle switch language trong settings:
- ✅ Tab labels cập nhật ngay lập tức
- ✅ UI labels cập nhật ngay lập tức
- ✅ Không cần click vào tab hoặc navigate
- ✅ Locale được persist vào storage
### Zustand Store Structure
```tsx
// /state/use-locale-store.ts
const { locale, setLocale, isLoaded } = useLocaleStore((state) => ({
locale: state.locale, // Locale hiện tại
setLocale: state.setLocale, // Async function để thay đổi locale
isLoaded: state.isLoaded, // Flag để biết khi locale đã load từ storage
}));
```
## Fallback Mechanism
Nếu một key không tồn tại trong ngôn ngữ hiện tại, ứng dụng sẽ tự động sử dụng giá trị từ ngôn ngữ English (default locale).
### Ví dụ:
- Nếu key `auth.newFeature` chỉ tồn tại trong `en.json` mà không có trong `vi.json`
- Khi ngôn ngữ được set là Vietnamese (`vi`), nó sẽ hiển thị giá trị từ English
## Persistence (Lưu trữ tùy chọn ngôn ngữ)
Ngôn ngữ được chọn tự động được lưu vào `AsyncStorage` với key `app_locale_preference`.
**Quy trình:**
1. Khi app khởi động, hook `useI18n` sẽ load giá trị từ storage
2. Nếu có giá trị lưu trữ, app sẽ sử dụng ngôn ngữ đó
3. Nếu không có, app sẽ detect ngôn ngữ thiết bị (`getLocales()`)
4. Khi người dùng gọi `setLocale('vi')`, nó sẽ:
- Thay đổi ngôn ngữ hiện tại
- **Tự động lưu vào storage** để dùng lần tiếp theo
**Kết quả:** Người dùng có thể tắt app, mở lại, và app sẽ vẫn sử dụng ngôn ngữ đã chọn trước đó.
## Supported Locales
Hiện tại app hỗ trợ:
- **en** - English
- **vi** - Tiếng Việt
Nếu muốn thêm ngôn ngữ khác:
1. Tạo file `locales/[language-code].json` (ví dụ: `locales/ja.json`)
2. Thêm translations
3. Import trong `config/localization/i18n.ts`:
```ts
import ja from "@/locales/ja.json";
const translations = {
en,
vi,
ja,
};
```
4. Cập nhật `app.json` để thêm locale mới:
```json
"supportedLocales": {
"ios": ["en", "vi", "ja"],
"android": ["en", "vi", "ja"]
}
```
## Translation Keys Hiện Có
Xem file `locales/en.json` và `locales/vi.json` để xem danh sách tất cả keys có sẵn.
## Best Practices
1. **Luôn add key vào cả 2 file** `en.json` và `vi.json` cùng lúc
2. **Giữ cấu trúc JSON nhất quán** giữa các language files
3. **Sử dụng snake_case cho keys** (không sử dụng camelCase)
4. **Nhóm liên quan keys** vào categories (ví dụ: `common`, `navigation`, `auth`, etc.)
5. **Để fallback enable** để tránh lỗi nếu key bị thiếu
## Device Language Detection
Ngôn ngữ của thiết bị sẽ được tự động detect khi app khởi động. Nếu thiết bị set language là Tiếng Việt, app sẽ tự động sử dụng `vi` locale.
Device language được lấy từ:
```tsx
import { getLocales } from "expo-localization";
const deviceLanguage = getLocales()[0].languageCode; // 'en', 'vi', etc.
```
## Troubleshooting
### App không detect đúng ngôn ngữ thiết bị
- Kiểm tra console log trong `config/localization/i18n.ts`
- Đảm bảo language code của thiết bị khớp với các supported locales
### Translation key không hiện
- Kiểm tra xem key có tồn tại trong cả 2 files `en.json` và `vi.json` không
- Kiểm tra spelling của key (case-sensitive)
- Kiểm tra syntax JSON có hợp lệ không
### Fallback không hoạt động
- Đảm bảo `i18n.enableFallback = true` trong `config/localization/i18n.ts`
- Kiểm tra key có tồn tại trong `en.json` không (default fallback language)
## References
- [Expo Localization Guide](https://docs.expo.dev/guides/localization/)
- [i18n-js Documentation](https://github.com/fnando/i18n-js)

409
MODAL_USAGE.md Normal file
View 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

View File

@@ -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
View File

@@ -0,0 +1,502 @@
# Theme System Documentation
## Tổng quan
Hệ thống theme hỗ trợ **Light Mode**, **Dark Mode****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****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
```

View File

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

View File

@@ -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} />
),

View File

@@ -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 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",
},
});

View File

@@ -1,169 +1,414 @@
import { showToastError } from "@/config";
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 SosButton from "@/components/map/SosButton";
import {
queryAlarm,
queryGpsData,
queryTrackPoints,
} from "@/controller/DeviceController";
ENTITY,
EVENT_ALARM_DATA,
EVENT_BANZONE_DATA,
EVENT_ENTITY_DATA,
EVENT_GPS_DATA,
EVENT_TRACK_POINTS_DATA,
IOS_PLATFORM,
LIGHT_THEME,
} from "@/constants";
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 { Image as ExpoImage } from "expo-image";
import { useState } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import eventBus from "@/utils/eventBus";
import {
convertWKTLineStringToLatLngArray,
convertWKTtoLatLngString,
} from "@/utils/geom";
import { useEffect, useRef, useState } from "react";
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";
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 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 [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<
PolylineWithLabelProps[]
>([]);
const [polygonCoordinates, setPolygonCoordinates] = useState<
PolygonWithLabelProps[]
>([]);
const platform = usePlatform();
const theme = useThemeContext().colorScheme;
const scale = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
getGpsEventBus();
getAlarmEventBus();
getEntitiesEventBus();
getBanzonesEventBus();
getTrackPointsEventBus();
const queryGpsData = (gpsData: Model.GPSResponse) => {
if (gpsData) {
// console.log("GPS Data: ", gpsData);
setGpsData(gpsData);
} else {
setGpsData(null);
setPolygonCoordinates([]);
setPolylineCoordinates([]);
}
};
const queryAlarmData = (alarmData: Model.AlarmResponse) => {
// console.log("Alarm Data: ", alarmData.alarms.length);
setAlarmData(alarmData);
};
const queryEntityData = (entityData: Model.TransformedEntity[]) => {
// console.log("Entities Length Data: ", entityData.length);
setEntityData(entityData);
};
const queryBanzonesData = (banzoneData: Model.Zone[]) => {
// console.log("Banzone Data: ", banzoneData.length);
setBanzoneData(banzoneData);
};
const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
// console.log("TrackPoints Data: ", TrackPointsData.length);
if (TrackPointsData && TrackPointsData.length > 0) {
setTrackPointsData(TrackPointsData);
} else {
setTrackPointsData([]);
}
};
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");
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");
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");
};
}, []);
useEffect(() => {
setPolylineCoordinates([]);
setPolygonCoordinates([]);
if (!entityData) return;
if (!banzoneData) return;
for (const entity of entityData) {
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
continue;
}
let zones: any[] = [];
try {
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
} catch (parseError) {
console.error("Error parsing zone list:", parseError);
continue;
}
// Nếu danh sách zone rỗng, clear tất cả
if (zones.length === 0) {
setPolylineCoordinates([]);
setPolygonCoordinates([]);
return;
}
let polylines: PolylineWithLabelProps[] = [];
let polygons: PolygonWithLabelProps[] = [];
for (const zone of zones) {
// console.log("Zone Data: ", zone);
const geom = banzoneData.find((b) => b.id === zone.zone_id);
if (!geom) {
continue;
}
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
if (typeof geom_type !== "number") {
continue;
}
if (geom_type === 2) {
// if(oldEntityData.find(e => e.id === ))
// foundPolyline = true;
const coordinates = convertWKTLineStringToLatLngArray(
geom_lines || ""
);
if (coordinates.length > 0) {
polylines.push({
coordinates: coordinates.map((coord) => ({
latitude: coord[0],
longitude: coord[1],
})),
label: zone?.zone_name ?? "",
content: zone?.message ?? "",
});
} else {
console.log("Không tìm thấy polyline trong alarm");
}
} else if (geom_type === 1) {
// foundPolygon = true;
const coordinates = convertWKTtoLatLngString(geom_poly || "");
if (coordinates.length > 0) {
// console.log("Polygon Coordinate: ", coordinates);
const zonePolygons = coordinates.map((polygon) => ({
coordinates: polygon.map((coord) => ({
latitude: coord[0],
longitude: coord[1],
})),
label: zone?.zone_name ?? "",
content: zone?.message ?? "",
}));
polygons.push(...zonePolygons);
} else {
console.log("Không tìm thấy polygon trong alarm");
}
}
}
setPolylineCoordinates(polylines);
setPolygonCoordinates(polygons);
}
}, [banzoneData, entityData]);
// Hàm tính radius cố định khi zoom change
const calculateRadiusFromZoom = (zoom: number) => {
const baseZoom = 10;
const baseRadius = 100;
const zoomDifference = baseZoom - zoom;
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
// console.log("Caculate Radius: ", calculatedRadius);
return Math.max(calculatedRadius, 50);
};
const getShipTrackPoints = async () => {
try {
const response = await queryTrackPoints();
console.log(
"TrackPoints Data: ",
response.data[response.data.length - 1]
);
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");
// Xử lý khi region (zoom) thay đổi
const handleRegionChangeComplete = (newRegion: any) => {
// Tính zoom level từ latitudeDelta
// zoom = log2(360 / (latitudeDelta * 2)) + 8
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
const newRadius = calculateRadiusFromZoom(zoom);
setCircleRadius(newRadius);
setZoomLevel(zoom);
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
};
// Tính toán region để focus vào marker của tàu (chỉ lần đầu tiên)
const getMapRegion = () => {
if (!isFirstLoad) {
// Sau lần đầu, return undefined để không force region
return undefined;
}
if (!gpsData) {
return {
latitude: 15.70581,
longitude: 116.152685,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
};
}
return {
latitude: gpsData.lat,
longitude: gpsData.lon,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
};
};
const handleMapReady = () => {
console.log("Map loaded successfully!");
getGpsData();
getAlarmData();
getShipTrackPoints();
setTimeout(() => {
setIsFirstLoad(false);
}, 2000);
};
// Tính toán region để bao phủ cả GPS và track points
const getMapRegion = () => {
if (!gpsData && (!trackPoints || trackPoints.length === 0)) {
return {
latitude: 15.70581,
longitude: 116.152685,
latitudeDelta: 2,
longitudeDelta: 2,
};
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();
}
let minLat = gpsData?.lat ?? 90;
let maxLat = gpsData?.lat ?? -90;
let minLon = gpsData?.lon ?? 180;
let maxLon = gpsData?.lon ?? -180;
// Bao gồm track points
if (trackPoints) {
trackPoints.forEach((point) => {
minLat = Math.min(minLat, point.lat);
maxLat = Math.max(maxLat, point.lat);
minLon = Math.min(minLon, point.lon);
maxLon = Math.max(maxLon, point.lon);
});
}
const latDelta = Math.max(maxLat - minLat, 0.01) * 1.2; // Padding 20%
const lonDelta = Math.max(maxLon - minLon, 0.01) * 1.2;
console.log("Map region:", {
minLat,
maxLat,
minLon,
maxLon,
latDelta,
lonDelta,
});
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2,
latitudeDelta: latDelta,
longitudeDelta: lonDelta,
};
};
}, [alarmData?.level, scale, opacity]);
return (
<SafeAreaProvider style={styles.container}>
<View
// edges={["top"]}
style={styles.container}
>
<MapView
onMapReady={handleMapReady}
onPoiClick={(point) => {
console.log("Poi clicked: ", point.nativeEvent);
}}
onRegionChangeComplete={handleRegionChangeComplete}
style={styles.map}
initialRegion={{
latitude: 15.70581,
longitude: 116.152685,
latitudeDelta: 2,
longitudeDelta: 2,
}}
// initialRegion={getMapRegion()}
region={getMapRegion()}
// userInterfaceStyle="dark"
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
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}
radius={200} // Tăng từ 50 → 1000m
fillColor="rgba(241, 12, 65, 0.8)" // Tăng opacity từ 0.06 → 0.8
strokeColor="rgba(221, 240, 15, 0.8)"
// zIndex={50}
// radius={platform === IOS_PLATFORM ? 200 : 50}
radius={circleRadius}
strokeColor="rgba(16, 85, 201, 0.7)"
fillColor="rgba(16, 85, 201, 0.7)"
strokeWidth={2}
/>
);
})}
{gpsData && (
{polylineCoordinates.length > 0 && (
<>
{polylineCoordinates.map((polyline, index) => (
<PolylineWithLabel
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}
/>
))}
</>
)}
{polygonCoordinates.length > 0 && (
<>
{polygonCoordinates.map((polygon, index) => {
return (
<PolygonWithLabel
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}
zoomLevel={zoomLevel}
/>
);
})}
</>
)}
{gpsData !== null && (
<Marker
key={
platform === IOS_PLATFORM
? `${gpsData.lat}-${gpsData.lon}`
: "gps-data"
}
coordinate={{
latitude: gpsData.lat,
longitude: gpsData.lon,
}}
title="Tàu của mình"
zIndex={100}
zIndex={20}
anchor={
platform === IOS_PLATFORM
? { x: 0.5, y: 0.5 }
: { x: 0.6, y: 0.4 }
}
tracksViewChanges={platform === IOS_PLATFORM ? true : undefined}
>
<View
<View className="w-8 h-8 items-center justify-center">
<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,
transform: [
{
rotate: `${
@@ -173,22 +418,19 @@ export default function HomeScreen() {
}deg`,
},
],
alignItems: "center",
justifyContent: "center",
}}
>
<ExpoImage
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)}
style={{ width: 32, height: 32 }}
/>
</View>
</View>
</Marker>
)}
</MapView>
<TouchableOpacity style={styles.button} onPress={handleMapReady}>
<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>
);
}
@@ -200,6 +442,7 @@ const styles = StyleSheet.create({
flex: 1,
},
button: {
// display: "none",
position: "absolute",
top: 50,
right: 20,
@@ -221,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",
},
});

View File

@@ -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",
},
});

View File

@@ -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,
},
});

View File

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

View File

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

View File

@@ -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 tài khoản?{" "}
<Text style={styles.linkText}>Đăng 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",
},
});

14
assets.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare module "*.png" {
const content: string;
export default content;
}
declare module "*.jpg" {
const content: string;
export default content;
}
declare module "*.svg" {
const content: string;
export default content;
}

BIN
assets/icons/en_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/icons/vi_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/images/owner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

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

View File

@@ -0,0 +1,148 @@
import { ANDROID_PLATFORM } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import { getPolygonCenter } from "@/utils/polyline";
import React, { useRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { MapMarker, Marker, Polygon } from "react-native-maps";
export interface PolygonWithLabelProps {
coordinates: {
latitude: number;
longitude: number;
}[];
label?: string;
content?: string;
fillColor?: string;
strokeColor?: string;
strokeWidth?: number;
zIndex?: number;
zoomLevel?: number;
}
/**
* Component render Polygon kèm Label/Text ở giữa
*/
export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
coordinates,
label,
content,
fillColor = "rgba(16, 85, 201, 0.6)",
strokeColor = "rgba(16, 85, 201, 0.8)",
strokeWidth = 2,
zIndex = 50,
zoomLevel = 10,
}) => {
if (!coordinates || coordinates.length < 3) {
return null;
}
const platform = usePlatform();
const markerRef = useRef<MapMarker>(null);
const centerPoint = getPolygonCenter(coordinates);
// Tính font size dựa trên zoom level
// Zoom càng thấp (xa ra) thì font size càng nhỏ
const calculateFontSize = (baseSize: number) => {
const baseZoom = 10;
// Giảm scale factor để text không quá to khi zoom out
const scaleFactor = Math.pow(2, (zoomLevel - baseZoom) * 0.3);
return Math.max(baseSize * scaleFactor, 5); // Tối thiểu 5px
};
const labelFontSize = calculateFontSize(12);
const contentFontSize = calculateFontSize(10);
// 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);
return (
<>
<Polygon
coordinates={coordinates}
fillColor={fillColor}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
zIndex={zIndex}
/>
{label && (
<Marker
ref={markerRef}
coordinate={centerPoint}
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={[
{
paddingHorizontal: 5 * paddingScale,
paddingVertical: 5 * paddingScale,
minWidth: 80,
maxWidth: 150 * minWidthScale,
},
]}
>
<Text
style={[styles.labelText, { fontSize: labelFontSize }]}
numberOfLines={2}
>
{label}
</Text>
{content && (
<Text
style={[
styles.contentText,
{ fontSize: contentFontSize, marginTop: 2 * paddingScale },
]}
numberOfLines={2}
>
{content}
</Text>
)}
</View>
</View>
</Marker>
)}
</>
);
};
const styles = StyleSheet.create({
markerContainer: {
alignItems: "center",
justifyContent: "center",
},
labelContainer: {
backgroundColor: "rgba(16, 85, 201, 0.95)",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 18,
borderWidth: 2,
borderColor: "#fff",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 5,
elevation: 8,
minWidth: 80,
maxWidth: 250,
},
labelText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
letterSpacing: 0.3,
textAlign: "center",
},
contentText: {
color: "#fff",
fontSize: 11,
fontWeight: "600",
letterSpacing: 0.2,
textAlign: "center",
opacity: 0.95,
},
});

View File

@@ -0,0 +1,112 @@
import { ANDROID_PLATFORM } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import {
calculateTotalDistance,
getMiddlePointOfPolyline,
} from "@/utils/polyline";
import React, { useRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { MapMarker, Marker, Polyline } from "react-native-maps";
export interface PolylineWithLabelProps {
coordinates: {
latitude: number;
longitude: number;
}[];
label?: string;
content?: string;
strokeColor?: string;
strokeWidth?: number;
showDistance?: boolean;
zIndex?: number;
}
/**
* Component render Polyline kèm Label/Text ở giữa
*/
export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
coordinates,
label,
content,
strokeColor = "#FF5733",
strokeWidth = 4,
showDistance = false,
zIndex = 50,
}) => {
if (!coordinates || coordinates.length < 2) {
return null;
}
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
coordinates={coordinates}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
zIndex={zIndex}
/>
{displayText && (
<Marker
ref={markerRef}
coordinate={middlePoint}
zIndex={zIndex + 10}
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}>
<Text
style={styles.labelText}
numberOfLines={2}
adjustsFontSizeToFit
>
{displayText}
</Text>
</View>
</View>
</Marker>
)}
</>
);
};
const styles = StyleSheet.create({
markerContainer: {
alignItems: "center",
justifyContent: "center",
},
labelContainer: {
backgroundColor: "rgba(255, 87, 51, 0.95)",
paddingHorizontal: 5,
paddingVertical: 5,
borderRadius: 18,
borderWidth: 1,
borderColor: "#fff",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 5,
elevation: 8,
minWidth: 80,
maxWidth: 180,
},
labelText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
letterSpacing: 0.3,
textAlign: "center",
},
});

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

View File

@@ -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(() => {

View File

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

View File

@@ -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
View 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",
},
});

View File

@@ -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]}>
đ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]}>
đ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>
);
};

View File

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

View File

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

View 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";

View File

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

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

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

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

View 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",
},
});

View File

@@ -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,
},
});

View 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",
},
});

View 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",
},
});

View 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,
},
});

View File

@@ -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",
},
});

View File

@@ -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",
},
});

View File

@@ -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",
},
});

View File

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

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

View File

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

View File

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

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

View File

@@ -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");
}

View File

@@ -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。",
@@ -24,7 +25,7 @@ const codeMessage = {
// Tạo instance axios với cấu hình cơ bản
const api: AxiosInstance = axios.create({
baseURL: "http://192.168.30.102:81", // Thay bằng API thật của bạn
timeout: 10000, // Timeout 10 giây
timeout: 20000, // Timeout 20 giây
headers: {
"Content-Type": "application/json",
},
@@ -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
View File

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

View File

@@ -0,0 +1,27 @@
import en from "@/locales/en.json";
import vi from "@/locales/vi.json";
import { getLocales } from "expo-localization";
import { I18n } from "i18n-js";
// Set the key-value pairs for the different languages you want to support
const translations = {
en,
vi,
};
const i18n = new I18n(translations);
// Set the locale once at the beginning of your app
// This will be set from storage in the useI18n hook, default to device language or 'en'
i18n.locale = getLocales()[0].languageCode ?? "vi";
// Enable fallback mechanism - if a key is missing in the current language, it will use the key from English
i18n.enableFallback = true;
// Set default locale to English if no locale is available
i18n.defaultLocale = "vi";
// Storage key for locale preference
export const LOCALE_STORAGE_KEY = "app_locale_preference";
export default i18n;

View File

@@ -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
View 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>
)}
/>
),
};

View File

@@ -1,15 +1,32 @@
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";
// Global Constants
export const IOS_PLATFORM = "ios";
export const ANDROID_PLATFORM = "android";
export const WEB_PLATFORM = "web";
export const AUTO_REFRESH_INTERVAL = 5000; // in milliseconds
export const LIGHT_THEME = "light";
export const DARK_THEME = "dark";
// Route Constants
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 = {
ZONE_ALARM_LIST: "50:2",
GPS: "50:1",
};
// API Path Constants
export const API_PATH_LOGIN = "/api/agent/login";
@@ -27,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

View File

@@ -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",
},
});

View File

@@ -2,11 +2,14 @@ import { api } from "@/config";
import {
API_GET_ALARMS,
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() {
@@ -16,3 +19,19 @@ export async function queryAlarm() {
export async function queryTrackPoints() {
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
}
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 });
}

View 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);
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/config";
import { API_GET_ALL_BANZONES } from "@/constants";
export async function queryBanzones() {
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
}

View 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);
}

View File

@@ -1,3 +1,5 @@
import * as AuthController from "./AuthController";
export { AuthController };
import * as DeviceController from "./DeviceController";
import * as MapController from "./MapController";
import * as TripController from "./TripController";
export { AuthController, DeviceController, MapController, TripController };

View File

@@ -7,7 +7,7 @@ declare namespace Model {
token?: string;
}
interface GPSResonse {
interface GPSResponse {
lat: number;
lon: number;
s: number;
@@ -33,4 +33,180 @@ declare namespace Model {
s: number;
h: number;
}
interface EntityResponse {
id: string;
v: number;
vs: string;
t: number;
type: string;
}
interface TransformedEntity {
id: string;
value: number;
valueString: string;
time: number;
type: string;
}
// Banzones
// Banzone
export interface Zone {
id?: string;
name?: string;
type?: number;
conditions?: Condition[];
enabled?: boolean;
updated_at?: Date;
geom?: Geom;
}
export interface Condition {
max?: number;
min?: number;
type?: Type;
to?: number;
from?: number;
}
export enum Type {
LengthLimit = "length_limit",
MonthRange = "month_range",
}
export interface Geom {
geom_type?: number;
geom_poly?: string;
geom_lines?: string;
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;
}
}

View File

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

View File

@@ -0,0 +1,62 @@
import { useCallback, useState } from "react";
/**
* Hook để tính radius cố định cho Circle trên MapView
* Radius sẽ được điều chỉnh dựa trên zoom level để giữ kích thước pixel cố định
*/
export const useFixedCircleRadius = (pixelRadius: number = 30) => {
const [radius, setRadius] = useState(100); // Giá trị default
const calculateRadiusFromZoom = useCallback((zoomLevel: number) => {
// Công thức: radius (meters) = pixelRadius * 156543.04 * cos(latitude) / 2^(zoomLevel + 8)
// Đơn giản hơn: radius tỉ lệ với 2^(maxZoom - currentZoom)
// Khi zoom = 14, dùng radius = 100 làm reference
const baseZoom = 14;
const baseRadius = 100;
// Mỗi level zoom tương ứng với 2x sự khác biệt
const zoomDifference = baseZoom - zoomLevel;
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
return Math.max(calculatedRadius, 10); // Minimum 10 meters
}, []);
const handleZoomChange = useCallback(
(zoomLevel: number) => {
const newRadius = calculateRadiusFromZoom(zoomLevel);
setRadius(newRadius);
},
[calculateRadiusFromZoom]
);
return {
radius,
handleZoomChange,
};
};
/**
* Alternative: Sử dụng Polygon thay vì Circle để có kích thước cố định theo pixel
* Tạo một hình tròn bằng Polygon với điểm tâm là coordinate
*/
export const createCircleCoordinates = (
center: { latitude: number; longitude: number },
radiusInMeters: number,
points: number = 36
) => {
const coordinates = [];
const latDelta = radiusInMeters / 111000; // 1 degree ~ 111km
for (let i = 0; i < points; i++) {
const angle = (i / points) * (2 * Math.PI);
const longitude =
center.longitude +
(latDelta * Math.cos(angle)) /
Math.cos((center.latitude * Math.PI) / 180);
const latitude = center.latitude + latDelta * Math.sin(angle);
coordinates.push({ latitude, longitude });
}
return coordinates;
};

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

@@ -0,0 +1,119 @@
import i18n, { LOCALE_STORAGE_KEY } from "@/config/localization/i18n";
import { getStorageItem, setStorageItem } from "@/utils/storage";
import { getLocales } from "expo-localization";
import {
PropsWithChildren,
createContext,
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
type SupportedLocale = "en" | "vi";
type I18nContextValue = {
t: typeof i18n.t;
locale: SupportedLocale;
setLocale: (locale: SupportedLocale) => Promise<void>;
isLoaded: boolean;
};
const I18nContext = createContext<I18nContextValue | undefined>(undefined);
const SUPPORTED_LOCALES: SupportedLocale[] = ["en", "vi"];
const resolveSupportedLocale = (
locale: string | null | undefined
): SupportedLocale => {
if (!locale) {
return "en";
}
const normalized = locale.split("-")[0]?.toLowerCase() as SupportedLocale;
if (normalized && SUPPORTED_LOCALES.includes(normalized)) {
return normalized;
}
return "en";
};
export const I18nProvider = ({ children }: PropsWithChildren<unknown>) => {
const [locale, setLocaleState] = useState<SupportedLocale>(
resolveSupportedLocale(i18n.locale)
);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const loadLocale = async () => {
try {
const savedLocale = await getStorageItem(LOCALE_STORAGE_KEY);
const deviceLocale = getLocales()[0]?.languageCode;
const localeToUse = resolveSupportedLocale(savedLocale ?? deviceLocale);
if (localeToUse !== i18n.locale) {
i18n.locale = localeToUse;
}
setLocaleState(localeToUse);
} catch (error) {
console.error("Error loading locale preference:", error);
} finally {
setIsLoaded(true);
}
};
void loadLocale();
}, []);
const updateLocale = useCallback((nextLocale: SupportedLocale) => {
if (i18n.locale !== nextLocale) {
i18n.locale = nextLocale;
}
setLocaleState(nextLocale);
}, []);
const setLocale = useCallback(
async (nextLocale: SupportedLocale) => {
if (!SUPPORTED_LOCALES.includes(nextLocale)) {
console.warn(`Unsupported locale: ${nextLocale}`);
return;
}
try {
updateLocale(nextLocale);
await setStorageItem(LOCALE_STORAGE_KEY, nextLocale);
} catch (error) {
console.error("Error setting locale:", error);
}
},
[updateLocale]
);
const translate = useCallback(
(...args: Parameters<typeof i18n.t>) => i18n.t(...args),
[locale]
);
const value = useMemo<I18nContextValue>(
() => ({
t: translate,
locale,
setLocale,
isLoaded,
}),
[locale, setLocale, translate, isLoaded]
);
return createElement(I18nContext.Provider, { value }, children);
};
export const useI18n = () => {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within an I18nProvider");
}
return context;
};

23
hooks/use-platform.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Platform } from "react-native";
export type PlatformType = "ios" | "android" | "web";
export const usePlatform = (): PlatformType => {
return Platform.OS as PlatformType;
};
export const useIsIOS = (): boolean => {
return Platform.OS === "ios";
};
export const useIsAndroid = (): boolean => {
return Platform.OS === "android";
};
export const useIsWeb = (): boolean => {
return Platform.OS === "web";
};
export const getPlatform = (): PlatformType => {
return Platform.OS as PlatformType;
};

View File

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

View File

@@ -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' });

2512
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,49 +5,66 @@
"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": {
"@types/react": "~19.1.0",
"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
View 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();
}

View File

@@ -8,8 +8,6 @@ import shipWarningFishingIcon from "../assets/icons/ship_warning_fishing.png";
import shipSosIcon from "../assets/icons/sos_icon.png";
export const getShipIcon = (type: number, isFishing: boolean) => {
console.log("type, isFishing", type, isFishing);
if (type === 1 && !isFishing) {
return shipWarningIcon;
} else if (type === 2 && !isFishing) {

View 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,
});
}

27
state/use-banzones.ts Normal file
View File

@@ -0,0 +1,27 @@
import { queryBanzones } from "@/controller/MapController";
import { create } from "zustand";
type Banzone = {
banzones: Model.Zone[];
getBanzone: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useBanzones = create<Banzone>()((set) => ({
banzones: [],
getBanzone: async () => {
set({ loading: true });
try {
const response = await queryBanzones();
console.log("Banzone fetching: ", response.data.length);
set({ banzones: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Banzones: ", error);
set({ error: "Failed to fetch banzone data", loading: false });
set({ banzones: [] });
}
},
error: null,
}));

26
state/use-fish.ts Normal file
View 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
View 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,
}));

View File

@@ -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: [],
};

View File

@@ -6,6 +6,9 @@
"paths": {
"@/*": [
"./*"
],
"tailwind.config": [
"./tailwind.config.js"
]
}
},

5
utils/eventBus.ts Normal file
View File

@@ -0,0 +1,5 @@
import EventEmitter from "eventemitter3";
const eventBus = new EventEmitter();
export default eventBus;

110
utils/geom.ts Normal file
View File

@@ -0,0 +1,110 @@
export const convertWKTPointToLatLng = (wktString: string) => {
if (
!wktString ||
typeof wktString !== "string" ||
!wktString.startsWith("POINT")
) {
return null;
}
const matched = wktString.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
if (!matched) return null;
const lng = parseFloat(matched[1]);
const lat = parseFloat(matched[2]);
return [lng, lat]; // [longitude, latitude]
};
export const convertWKTLineStringToLatLngArray = (wktString: string) => {
if (
!wktString ||
typeof wktString !== "string" ||
!wktString.startsWith("LINESTRING")
) {
return [];
}
const matched = wktString.match(/LINESTRING\s*\((.*)\)/);
if (!matched) return [];
const coordinates = matched[1].split(",").map((coordStr) => {
const [x, y] = coordStr.trim().split(" ").map(Number);
return [y, x]; // [lat, lng]
});
return coordinates;
};
export const convertWKTtoLatLngString = (wktString: string) => {
if (!wktString || typeof wktString !== "string") return [];
const clean = wktString.trim();
// MULTIPOLYGON
if (clean.startsWith("MULTIPOLYGON")) {
const matched = clean.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
if (!matched) return [];
const polygons = matched[1].split(")),((").map((polygonStr) =>
polygonStr
.trim()
.split(",")
.map((coordStr) => {
const [lng, lat] = coordStr.trim().split(/\s+/).map(Number);
return [lat, lng]; // Đảo ngược: [latitude, longitude]
})
);
return polygons; // Mỗi phần tử là 1 polygon (mảng các [lat, lng])
}
// POLYGON
if (clean.startsWith("POLYGON")) {
const matched = clean.match(/POLYGON\s*\(\((.*)\)\)/);
if (!matched) return [];
const polygon = matched[1].split(",").map((coordStr) => {
const [lng, lat] = coordStr.trim().split(/\s+/).map(Number);
return [lat, lng];
});
return [polygon];
}
return [];
};
export const getBanzoneNameByType = (type: number) => {
switch (type) {
case 1:
return "Cấm đánh bắt";
case 2:
return "Cấm di chuyển";
case 3:
return "Vùng an toàn";
default:
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
}

157
utils/polyline.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* Utility functions for Polyline
*/
export interface LatLng {
latitude: number;
longitude: number;
}
/**
* Tìm điểm ở giữa của polyline
*/
export const getMiddlePointOfPolyline = (coordinates: LatLng[]): LatLng => {
if (coordinates.length === 0) {
return { latitude: 0, longitude: 0 };
}
if (coordinates.length === 1) {
return coordinates[0];
}
const middleIndex = Math.floor(coordinates.length / 2);
return coordinates[middleIndex];
};
/**
* Tính toán điểm ở giữa của 2 điểm
*/
export const getMidpoint = (point1: LatLng, point2: LatLng): LatLng => {
return {
latitude: (point1.latitude + point2.latitude) / 2,
longitude: (point1.longitude + point2.longitude) / 2,
};
};
/**
* Tính khoảng cách giữa 2 điểm (Haversine formula)
* Trả về khoảng cách theo km
*/
export const calculateDistance = (point1: LatLng, point2: LatLng): number => {
const R = 6371; // Bán kính trái đất (km)
const dLat = (point2.latitude - point1.latitude) * (Math.PI / 180);
const dLon = (point2.longitude - point1.longitude) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(point1.latitude * (Math.PI / 180)) *
Math.cos(point2.latitude * (Math.PI / 180)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
/**
* Tính tổng khoảng cách của polyline
*/
export const calculateTotalDistance = (coordinates: LatLng[]): number => {
if (coordinates.length < 2) return 0;
let totalDistance = 0;
for (let i = 0; i < coordinates.length - 1; i++) {
totalDistance += calculateDistance(coordinates[i], coordinates[i + 1]);
}
return totalDistance;
};
/**
* Tính heading (hướng) giữa 2 điểm
* Trả về góc độ (0-360)
*/
export const calculateHeading = (point1: LatLng, point2: LatLng): number => {
const dLon = point2.longitude - point1.longitude;
const lat1 = point1.latitude * (Math.PI / 180);
const lat2 = point2.latitude * (Math.PI / 180);
const dLonRad = dLon * (Math.PI / 180);
const y = Math.sin(dLonRad) * Math.cos(lat2);
const x =
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLonRad);
const bearing = Math.atan2(y, x) * (180 / Math.PI);
return (bearing + 360) % 360;
};
/**
* Tính điểm trung tâm (centroid) của polygon
* Sử dụng thuật toán Shoelace formula để tính centroid chính xác
* Thuật toán này tính centroid dựa trên diện tích, phù hợp với polygon bất kỳ
*/
export const getPolygonCenter = (coordinates: LatLng[]): LatLng => {
if (coordinates.length === 0) {
return { latitude: 0, longitude: 0 };
}
if (coordinates.length === 1) {
return coordinates[0];
}
if (coordinates.length === 2) {
return {
latitude: (coordinates[0].latitude + coordinates[1].latitude) / 2,
longitude: (coordinates[0].longitude + coordinates[1].longitude) / 2,
};
}
let area = 0;
let centroidLat = 0;
let centroidLon = 0;
// Đảm bảo polygon đóng (điểm đầu = điểm cuối)
const coords = [...coordinates];
if (
coords[0].latitude !== coords[coords.length - 1].latitude ||
coords[0].longitude !== coords[coords.length - 1].longitude
) {
coords.push(coords[0]);
}
// Tính diện tích và centroid sử dụng Shoelace formula
for (let i = 0; i < coords.length - 1; i++) {
const lat1 = coords[i].latitude;
const lon1 = coords[i].longitude;
const lat2 = coords[i + 1].latitude;
const lon2 = coords[i + 1].longitude;
const cross = lat1 * lon2 - lon1 * lat2;
area += cross;
centroidLat += (lat1 + lat2) * cross;
centroidLon += (lon1 + lon2) * cross;
}
area = area / 2;
// Nếu diện tích quá nhỏ (polygon suy biến), dùng trung bình đơn giản
if (Math.abs(area) < 0.0000001) {
let latSum = 0;
let lonSum = 0;
for (const coord of coordinates) {
latSum += coord.latitude;
lonSum += coord.longitude;
}
return {
latitude: latSum / coordinates.length,
longitude: lonSum / coordinates.length,
};
}
centroidLat = centroidLat / (6 * area);
centroidLon = centroidLon / (6 * area);
return {
latitude: centroidLat,
longitude: centroidLon,
};
};

91
utils/sosUtils.ts Normal file
View 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.",
},
];

11
utils/tranform.ts Normal file
View File

@@ -0,0 +1,11 @@
export function transformEntityResponse(
raw: Model.EntityResponse
): Model.TransformedEntity {
return {
id: raw.id,
value: raw.v,
valueString: raw.vs,
time: raw.t,
type: raw.type,
};
}